Understanding Domain-Driven Design (Part 2)

Understanding Domain-Driven Design (Part 2)

Photo by Kaleidico on Unsplash

Introduction

Hey there! Welcome back to Part 2 of the “Understanding Domain-Driven Design” series. It’s awesome to see the positive feedback from Part 1. After a decade in the industry, I’ve tried my best to explain what I learned about Domain-Driven Design (DDD). Today, we’re going to talk about applying Domain-Driven Design (DDD). If you haven’t read Part 1 yet, I strongly suggest you read that first.

Focusing on The Actual Problem

Every domain has its unique set of problems. Let’s name a few: the travel industry, trade industry, real estate industry, auto industry, finance and banking industry, airline industry, commerce industry, and the list goes on… They all represent complex spaces. Our job is to abstract and simplify their solutions into a digital environment. Therefore, we need to focus on what matters. We want to design our software in a way that technical problems won’t affect us.

Are Frameworks That Bad?

No, frameworks are not that bad. They’re just tools. They’re okay when you use them effectively. They’re there to help you. However, they become problematic when they dictate your project structure, dependencies, and development approach. They become problematic when you become too dependent on them. You shift your attention from your domain to the framework, a technical detail. When tackling your own issues, you go to the framework documentation to find the best solution. Frameworks don’t understand your problems and needs. What if they suggest bad practices? Have you ever thought about this? That’s not the way it should be. I’ll show you another way in this series. Please, keep up with me. I promise you a mind shift to Domain-Driven Design (DDD) and it will be easy! We will try to combine the best of both worlds.

Use Case-Driven Approach

When modeling our domain, we will always think about its use cases. We will stick with the real-world concepts. Ask yourself, “What problem are you solving?”. For instance, imagine buying a flight ticket, before the computer era? Let’s break down this process step by step:

  1. You would visit a travel agency or airline office.

  2. Ask about available flights and ticket prices.

  3. Choose a flight.

  4. Make the payment.

  5. Get your paper ticket, or the ticket might be mailed to your address.

This process is still the same, except now the agent is a website or a mobile app. All those steps are use cases. We still solve the same problem: buying a flight ticket. You search for flights online, view available flights and ticket prices, choose a flight, make the payment, and finally receive your ticket in your inbox. We have abstracted this entire process and made it more efficient and accessible with today’s digital systems. The GetAvailableFlights is a use case. The BookFlight is a use case. The MakePayment is another use case. We want to focus on abstracting this particular aspect of the business, initially. Not the routing, or not the caching. These are not even important, yet. We don’t serve any users. Caching is a concern for an application that has performance issues. We’re not there, yet.

This little mind shift will also affect how we write our tests. When we want to validate our use cases, we can always provide test doubles. You don’t have to run Redis or MySQL to prove that your use case–the important part, works as expected. You can create an in-house InMemory implementations for different concerns. This will help you to test all of your use cases, without setting up a single infrastructural component. This is how you can start a project: create one folder and open a text editor. No need to install anything yet. This is freedom. We can always replace the InMemory drivers with the actual implementations, in the production configuration, before we launch. We simply need to provide an interface; A connection point between the infrastructure and the domain. This is where the mental model of separation comes into play. Don’t worry if this sounds a bit complex. We will dive into this topic later.

Good Software Architecture

There is an important nuance with this structure and design. You need to understand that, as a software architect, you have to keep it clean and teach others how to keep it clean. The changes made in the UI components shouldn’t affect the use cases. Also, the changes in the use cases shouldn’t affect the domain entities. If you make changes in a REST controller, they shouldn’t change how your business operates. You need to have a strong mental model of separation. The location of files, next to each other or in different folders, shouldn’t really affect this mental model. You can organize things in a way that makes sense. A good software architecture allows for changes without high costs. It’s the one where you can make changes without saying “We need to create this project from scratch”.

Understanding Use Case

A Use Case is a simple set of instructions to the computer, describing our task. You minimize your contextual complexity within each Use Case. You solve a small problem. Also, to understand the same problem, anyone just needs to study a small folder. Isn’t that amazing? Just imagine how much less explanation you need when someone joins your team. And how much less cognitive load they will have. Let’s create our first Use Case, the CreatePost.

Creating a Use Case

namespace Platform\ContentManagement\CreatePost;

use DateTimeImmutable;
use PublishMate\Platform\ContentManagement\PostRepository;

final readonly class CreatePost  
{
    public function __construct(  
        private PostRepository $postRepository,  
    ) {  
    }

    public function create(
        string $title,  
        string $content,
    ): void {  
        $post = new Post($title, $content);
        $this->postRepository->save($post);
    }  
}

That’s it? Yeah, that’s it! I told you it would be simple. I can hear you saying, “Bullshit! A real app would be much more complex!”. Let’s make it a bit more like a real-world app.

namespace Platform\ContentManagement\CreatePost;  

use DateTimeImmutable;  
use PublishMate\Platform\ContentManagement\Author;
use PublishMate\Platform\ContentManagement\MarkdownConverter; use PublishMate\Platform\ContentManagement\Post;  
use PublishMate\Platform\ContentManagement\PostId;  
use PublishMate\Platform\ContentManagement\PostRepository;
use PublishMate\Platform\Shared\Event\EventDispatcher;  

final readonly class CreatePost  
{  
    public function __construct(  
        private PostRepository $postRepository,  
        private EventDispatcher $eventDispatcher,
        private MarkdownConverter $markdownConverter,  
    ) {  
    }  

    public function create(  
        Author $author,  
        string $title,  
        string $slug,  
        string $contentMarkdown,  
        string $summary,  
        string $canonicalUrl,  
        ?string $coverImageUrl = null,  
        DateTimeImmutable $createdAt = new DateTimeImmutable(),  
        DateTimeImmutable $publishedAt = new DateTimeImmutable(),  
    ): void {
        $htmlContent = $this->markdownConverter->toHtml($markdownContent);

        $post = new Post(  
            author: $author,  
            title: $title,  
            slug: $slug,  
            contentMarkdown: $contentMarkdown,
            contentHtml: $htmlContent,  
            summary: $summary,  
            canonicalUrl: $canonicalUrl,  
            coverImageUrl: $coverImageUrl,  
            createdAt: $createdAt,  
            publishedAt: $publishedAt,  
        );  

        $this->postRepository->save($post);  

        $this->eventDispatcher->dispatch(  
            new PostCreated($post->id),  
        );  
    }  
}

This is more or less a real-world use case. I will simplify the other examples for the article's purposes, but you can still understand my point. Of course, your domain has many use cases, dozens if not hundreds, and they all require different complex solutions, but your code doesn’t have to be complex. Coding is a tool to simplify those complex problems.

Testing a Use Case

How do you ensure this use case works as expected? You create a test case. I usually start with a test case and then build my way up to the complete use case. Why? Because this helps me design for the expected outcome. This is called Test-Driven Development (TDD). It’s a technique that is often misunderstood. It’s not just about getting your test coverage to 100%. Ironically, it’s not only even about testing; it is also a design approach that helps you understand how the computer executes your instructions. I usually follow the AAA pattern:

  • Arrange refers to the initial setup of your use case. This could be something like “I need a repository to save a post” or “I need an author to create a post”. Essentially, it’s about the requirements to execute the task.

  • Act refers to the actual execution.

  • Assert means comparing the expected outcome to the actual result to ensure they align.

Let’s apply each step:

public function it_creates_blog_post(): void
{
    // Arrange
    $postRepository = new InMemoryPostRepository();
    $useCase = new CreatePost($postRepository);
}

Now that we have arranged the requirements, we can execute the task.

public function it_creates_blog_post(): void
{
    // Arrange
    $postRepository = new InMemoryPostRepository();
    $useCase = new CreatePost($postRepository);

    // Act
    $useCase->create('Title', 'Content');
}

We can finally verify if the post was actually created.

public function it_creates_blog_post(): void
{
    // Arrange
    $postRepository = new InMemoryPostRepository();
    $useCase = new CreatePost($postRepository);

    // Act
    $useCase->create('Title', 'Content');

    // Assert
    $actualPost = $postRepository->getLastCreatedPost();
    $expectedPost = Post::createWithId($actualPost->id, 'Title', 'Content');

    self::assertEquals($expectedPost, $actualPost);
}

Here we go—simple and easy. Once we run the test, we can confirm that our use case leads to the expected outcome. It is essential to test the behavior of the use case, not the implementation details. For instance, if you’re testing a database query, you need to ensure that the result is correct when you execute it. You don't need to validate if the SQL query is correctly written. Automated testing is a complex topic, and unfortunately, this is the only explanation I can provide in this series. But don’t worry, there’s good news! I plan to create an extensive guide about automated testing, too!

Delaying Important Decisions

Good architecture allows you to delay major decisions like “What database are we going to use?”, “Which ORM tool should we pick?”, “What framework should we adopt?”. These are not the questions you should ask at the beginning of your project. They are tools, and you can pick them up whenever you need them. They should be plug-and-play. They shouldn’t dictate how you operate. This model enables you to replace any infrastructure parts without creating a huge fuss. As you might have noticed, we used the InMemoryPostRepository, which implements the PostRepository interface, just to validate our use case. We delayed the decision to choose a database. The Use Case-Driven Approach lets you focus on coding the important part rather than worrying about which tool to choose.

Gluing Use Cases and Frameworks

Now that we have isolated our use case, we are free to choose any framework or tool we want. The process is simple: just inject the use case as a dependency and execute with the parameters. We still need to keep frameworks at a distance; we don't want to mix tools with our business. We want to be able to replace them whenever needed. Let’s create a basic Laravel controller using our use case:

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\View\View;

class CreatePostController extends Controller
{
    public function __construct(
        private CreatePost $createPostUseCase,
    ) {  
    }

    public function createPost(Request $request): View
    {
        $this->createPostUseCase->createPost(
            $request->input('title'), 
            $request->input('content'),
        );

        return view('post.created');
    }
}

Conclusion

Great job finishing Part 2! We’ve learned about the Use Case-Driven Approach, and Test-Driven Development (TDD). Feel free to share your thoughts in the comments! I hope to publish every week, on Sunday. In the next part, we will focus more on the details such as domain entities, events, and other abstractions, and discuss ways to simplify them.

If you enjoyed this article, let’s connect on https://twitter.com/akmandev and https://www.linkedin.com/in/ozanakman for more content like this!