Architecture of Complex Web Applications. With Laravel Examples

repository·master·Indexed 23 days ago

https://github.com/adelf/acwa_book_ru

A digital version of the book by Adel Fayzrakhmanov (2nd Edition) focusing on advanced software architecture patterns within the Laravel ecosystem. Key topics include Dependency Injection, Application and Domain Layer design, Event-driven architecture (CQRS, Event Sourcing), Unit Testing, and refactoring techniques to avoid common 'bad habits' like monster methods and SRP violations.

Tokens
42.1K
Snippets
78
Records
133
Agent score
83%

What's inside acwa_book_ru

  1. Overview of Architecture of Complex Web Applications with Laravel Examples

    master

    This repository contains the content for the book "Architecture of Complex Web Applications. With Laravel Examples" (Second Edition) by Adel Fayzrakhmanov. The book covers advanced architectural patterns and best practices for building robust web applications using the Laravel framework.

    Key topics covered include:

    • Dependency Injection
    • Refactoring techniques
    • Application Layer design
    • Error handling and Validation
    • Event-driven architecture (Events, CQRS, and Event Sourcing)
    • Domain Layer design
    • Unit Testing
  2. What is Dependency Injection (DI) and why use it?

    master

    Dependency Injection (DI) is a technique where classes 'ask' for their required dependencies instead of creating them internally (e.g., using new ClassName() or static methods).

    Benefits:

    • Decoupling: Reduces hard dependencies between classes, making the system less like a 'metal mesh' where changing one part breaks many others.
    • Testability: Allows replacing real dependencies with 'mocks' during unit testing.
    • Flexibility: Enables swapping implementations (e.g., changing from local storage to S3) by updating a central configuration rather than modifying business logic.

    Instead of hardcoding a dependency:

    $imageUploader = new ImageUploader();
    $imageUploader->upload(...);

    You inject it via the constructor or method arguments.

  3. Decouple database logic from controllers using Service classes

    master

    To avoid violating the Single Responsibility Principle (SRP) and creating high coupling, do not perform database lookups (like Post::find($id)) inside Web Controllers or Console Commands. Instead, encapsulate both the data retrieval and the business logic within a Service class. The controller should only pass the necessary identifier (e.g., $id) to the service, and the service should handle the database interaction internally. This ensures that changes to the database schema or infrastructure only require changes within the Service layer, rather than across all application interfaces (Web, API, Console).

    class PostController
    {
        public function publish($id, PostService $postService)
        {
            // The controller only passes the ID, not the entity
            if (!$postService->publish($id)) {
                return redirect()->back()->withMessage('...');
            }
            
            return redirect()->route('posts');
        }
    }
    
    final class PostService
    {
        public function publish(int $id)
        {
            // The service handles the database lookup and logic
            $post = Post::find($id);
                
            if (!$post) {
                return false;
            }
            
            $post->published = true;
            
            return $post->save();
        }
    }
  4. Understand the differences between testing types

    master

    The project distinguishes between several testing methodologies to ensure application quality:

    • Unit Testing: Focuses on verifying the correctness of individual components in isolation.
    • Integration Testing: Verifies the collaborative work of multiple modules. For example, testing if a UserService correctly registers a user by checking if a database row is created, a UserRegistered event is generated, and an email command is dispatched.
    • Functional Testing (Acceptance or E2E - end to end): Verifies that the application meets functional requirements from a user perspective. This typically involves simulating user actions in a browser, such as navigating to a page, filling out forms, clicking buttons, and verifying the outcome (e.g., checking if a new entity appears on a specific page).
  5. Avoid Copy-Paste Driven Development

    master
    Copying and pasting logic to multiple locations provides short-term productivity but creates long-term maintenance debt. When logic is duplicated, it becomes impossible to maintain consistency and update the logic effectively across the entire application. Always aim to extract shared logic into dedicated classes or methods rather than duplicating it.
  6. Understand error handling strategies: Ascetics vs. The One True Path

    master

    When designing error handling in web applications, two primary architectural schools of thought exist:

    1. The Ascetics (Exceptions only for exceptional situations):

      • Exceptions are reserved for truly unexpected events (e.g., database failure, filesystem errors).
      • Expected failures (e.g., invalid email, wrong password) are handled by returning a result object, such as a FunctionResult.
      • Pros: Logic is explicit; errors are not "thrown" up the stack.
      • Cons: Requires constant manual checking of return values (e.g., if ($result->isError())) throughout the Application Layer and Controllers.
    2. The One True Path (Any negative situation is an exception):

      • Any deviation from the successful execution path (invalid data, unauthorized access, server failure) triggers an exception.
      • Pros: Results in cleaner, more unified code. Methods focus on the "happy path," and errors automatically propagate to a central handler.
      • Cons: Requires a mechanism to distinguish between Client Errors (4xx) and Server Errors (5xx) at the boundary (e.g., in the Controller or Global Exception Handler) to ensure correct HTTP status codes and logging levels.

    In the context of Laravel, the book follows The One True Path, utilizing Laravel's HttpException to specify HTTP codes and Eloquent's *OrFail() methods to trigger exceptions automatically.

  7. Avoid creating 'monster methods' with conditional logic

    master

    When refactoring code to eliminate duplication between store and update methods, avoid the trap of creating a single method that handles both actions via a boolean flag (e.g., updateOrCreateUser(..., boolean $update)).

    As requirements evolve, the logic for creating a resource and updating a resource will inevitably diverge. Combining them into one method leads to 'monster methods' filled with complex if ($update) conditional blocks, which are difficult to debug and maintain.

    Best Practice:

    • Keep create and update logic in separate methods.
    • Identify truly identical logic (e.g., file uploads, specific calculations) and extract only that shared logic into dedicated methods or classes with precise names.
    • Use meaningful naming to identify architectural smells: names containing "Or" (like updateOrCreate) often signal that a single method is trying to perform two distinct logical operations.
    // BAD: A monster method that tries to handle two different lifecycles
    protected function updateOrCreateUser(..., boolean $update)
    {
        if ($update)...
        if ($update)...
        if (!$update)...
    }
  8. Separate read and write responsibilities in service classes

    master

    A typical service class often mixes read methods (e.g., getById, getLatestPosts) and write methods (e.g., create, publish, delete). This violates the Single Responsibility Principle and complicates refactoring, such as adding caching. Since caching is typically only relevant for read operations, you should separate these responsibilities into distinct classes or interfaces.

    To implement this, keep your write-only service (e.g., PostService) and create a separate interface (e.g., PostQueries) for read operations. This allows you to use the Decorator pattern to add caching to the read operations without affecting the write logic.

    final class PostService
    {
        public function create(PostCreateDto $dto){}
        public function publish($postId){}
        public function delete($postId){}
    }
    
    interface PostQueries
    {
        public function getById($id): Post;
        public function getLatestPosts(): array;
        public function getAuthorPosts($authorId): array;
    }
    
    final class DatabasePostQueries implements PostQueries{}
    
    final class CachedPostQueries implements PostQueries
    {
        public function __construct(
            private PostQueries $baseQueries,
            private Cache $cache,
        ) {}
        
        public function getById($id): Post
        {
            return $this->cache->remember('post_' . $id, 
                function() use($id) {
                    return $this->baseQueries->getById($id);
                });
        }
    }
  9. Avoid the `$model->update($request->all())` pattern

    master

    Using $model->update($request->all()) is a common but dangerous habit. It creates a loss of control because the code no longer explicitly states what business logic is being executed. It treats the application as a mere interface to database rows rather than a system of domain objects.

    The Problem (Treating symptoms): Developers often try to fix the lack of control by observing changes after the fact, which is error-prone:

    function afterUserUpdate(User $user)
    {
        if (!$user->getOriginal('isBanned') && $user->isBanned) {
            // Send 'banned' email
        }
    }

    The Solution (Treating the cause): Instead of generic updates, use explicit methods or command classes that represent the business action. This makes the intent clear and provides a single point to trigger necessary side effects.

    Recommended patterns:

    • UserController::ban(int $id)
    • $user->ban()
    • BanUserCommand
  10. Using Traits to split large classes (Partial Class pattern)

    master

    In some frameworks like Laravel, traits are used to split a large class into multiple files (similar to partial class in C#). For example, the Laravel Request class uses several traits like InteractsWithContentTypes and InteractsWithInput to manage its various responsibilities.

    Architectural Warning: If a class needs to be split using traits, it is often a sign that the class has too many responsibilities (violating the Single Responsibility Principle). Instead of using traits to split a class, it is better to decompose the class into smaller, focused objects and compose the main class using Dependency Injection.

    // Example of a class split by traits (Laravel style)
    class Request extends SymfonyRequest 
        implements Arrayable, ArrayAccess
    {
        use Concerns\InteractsWithContentTypes,
            Concerns\InteractsWithFlashData,
            Concerns\InteractsWithInput;
    }
    
    // Better approach: Composition via DI
    class Request
    {
        private $session;
        private $input;
        private $cookies;
    
        public function __construct(
            Session $session, 
            RequestInput $input, 
            Cookies $cookies
        ) {
            $this->session = $session;
            $this->input = $input;
            $this->cookies = $cookies;
        }
    }
  11. Extend third-party interfaces using the Wrapper Interface pattern

    master

    Since PHP does not support extension methods (like Kotlin or C#), you cannot directly add methods to interfaces belonging to external libraries (e.g., Laravel's Dispatcher).

    To add functionality like multiDispatch to an existing interface without using inheritance (which complicates constructors), use the Wrapper Interface pattern:

    1. Define a new, minimal interface containing only the additional methods you need.
    2. Create a concrete implementation that wraps the original third-party service.
    3. Bind the new interface to the wrapper implementation in your application's Service Provider.
    4. Inject the new interface into your service classes instead of the original one.

    This approach keeps your services clean, avoids parent::__construct boilerplate, and reduces dependency on specific library implementations.

    // 1. Define the new minimal interface
    interface MultiDispatcher
    {
        public function multiDispatch(array $events);
    }
    
    // 2. Implement the wrapper
    use Illuminate\Contracts\Events\Dispatcher;
    
    final class LaravelMultiDispatcher implements MultiDispatcher
    {
        /** @var Dispatcher */
        private $dispatcher;
    
        public function __construct(Dispatcher $dispatcher)
        {
            $this->dispatcher = $dispatcher;
        }
    
        public function multiDispatch(array $events)
        {
            foreach($events as $event) {
                $this->dispatcher->dispatch($event);
            }
        }
    }
    
    // 3. Bind in Service Provider
    class AppServiceProvider extends ServiceProvider
    {
        public function boot()
        {
            $this->app->bind(
                MultiDispatcher::class, 
                LaravelMultiDispatcher::class
            );
        }
    }
    
    // 4. Use in your service
    final class SomeService
    {
        /** @var MultiDispatcher */
        private $dispatcher;
        
        public function __construct(..., MultiDispatcher $dispatcher)
        {
            $this->dispatcher = $dispatcher;
        }
    
        public function someMethod()
        {
            $this->dispatcher->multiDispatch($events);
        }
    }
  12. Avoid Feature Envy by delegating logic to the appropriate entity

    master

    When a class performs extensive operations on the internal data of another class, it suffers from Feature Envy. To fix this, move the logic into the class that owns the data. This keeps the domain model cohesive and prevents one class from needing to know too much about the internal implementation of another.

    Example: Moving validation from Job to Proposal

    Instead of Job iterating through proposals to check if a freelancer has already applied, the Proposal class should handle its own compatibility check.