Domain-Driven Hexagon

repository·master·Indexed 12 days ago

https://github.com/sairyss/domain-driven-hexagon

Architectural recommendations and patterns for designing scalable software using Domain-Driven Design (DDD), Hexagonal Architecture (Ports and Adapters), Clean Architecture, and SOLID principles. Version 2.0.0 provides a framework for building technology-independent applications, featuring guidance on Application Core layers, Command-Query Separation (CQS), and vertical slicing. Examples are implemented using NodeJS, TypeScript, NestJS, and Slonik.

Tokens
19.3K
Snippets
49
Records
85
Agent score
89%

What's inside Domain-Driven Hexagon

  1. Overview of Domain-Driven Hexagon Architecture

    master

    Domain-Driven Hexagon is a software design recommendation that combines multiple architectural patterns, including Domain-Driven Design (DDD), Hexagonal (Ports and Adapters) Architecture, Clean Architecture, Onion Architecture, and SOLID principles.

    It is designed to provide a framework for building scalable, testable, and secure applications that are independent of external frameworks, technologies, or databases.

    Key Technologies used in examples:

    • NodeJS
    • TypeScript
    • NestJS
    • Slonik (for database access)

    Note: The patterns and principles are framework/language agnostic. The technologies mentioned are used for demonstration purposes and can be replaced with alternatives.

  2. Separate Domain and Persistence models

    master

    In Domain-Driven Design (DDD), you should separate your domain models from your persistence models to avoid a database-centric architecture.

    • Domain Models: Consist of Entities, Aggregates, and Value Objects. They are shaped to best accommodate complex business logic.
    • Persistence Models: Consist of ORM models, database schemas, or read/write models (in CQRS). They are shaped to optimize database performance, storage, or data integrity.

    Why separate them? Separation prevents database changes (like normalization or denormalization) from forcing refactors in your domain layer. It allows the domain to remain agnostic of the underlying storage technology.

    Implementation Pattern:

    1. Define domain entities in the Domain layer.
    2. Define persistence models (e.g., UserModel, userSchema) in the Infrastructure/Database layer.
    3. Use a Mapper to convert between domain models and persistence models.

    Note: For small, data-centric applications with little business logic, this separation might be overkill due to the boilerplate required for mappers and abstractions.

    /* Example pattern structure */
    // src/modules/user/database/user.repository.ts -> defines UserModel and userSchema
    // src/modules/user/user.mapper.ts -> maps between Domain Entity and UserModel
  3. Understand the Application Core layers

    master

    The system is structured into two primary layers within the Application Core:

    Domain Layer

    Contains the fundamental business logic and rules:

    • Entities: Objects with unique identity.
    • Aggregates: Clusters of domain objects treated as a single unit.
    • Domain Services: Logic that doesn't naturally belong to a single Entity.
    • Value Objects: Objects defined by their attributes rather than identity.
    • Domain Errors: Specific error types related to business rule violations.

    Application Layer

    Orchestrates the domain logic and manages external interactions:

    • Application Services: Use cases/workflows.
    • Commands and Queries: Intent-based objects for CQS.
    • Ports: Interfaces for infrastructural dependencies (e.g., database, email services).
  4. Implement Interface Adapters (Controllers and Resolvers)

    master

    Interface Adapters (Driving/Primary Adapters) act as the entry point to your application. They translate user input (requests) into a format suitable for the Application Core and translate core output into a format suitable for the user (responses).

    Controllers

    • Purpose: Parse requests, trigger business logic (commands/queries), and present results.
    • Pattern: It is considered best practice to implement one controller per use case.
    • Multiple Triggers: You can implement multiple controllers for the same use case to support different protocols:
      • *.http.controller.ts for REST/HTTP (e.g., NestJS Controllers).
      • *.cli.controller.ts for Command Line Interfaces (e.g., NestJS Console).
      • *.message.controller.ts for Microservices/Message Brokers.
      • *.graphql-resolver.ts for GraphQL (Resolvers).

    By separating these, the Application Core remains agnostic of whether the trigger was an HTTP request or a CLI command.

  5. Use the Repository Pattern for data access

    master

    Repositories act as an abstraction over collections of entities, decoupling the domain model from the underlying persistence technology (SQL, NoSQL, files, etc.).

    Data Flow

    1. The Application Service calls a repository via a Port (interface).
    2. The Repository Implementation (in the Infrastructure layer) receives the domain Entity.
    3. The repository maps the Entity to a database schema/ORM format.
    4. The repository performs the operation (save, update, retrieve).
    5. The repository maps the result back to a domain Entity and returns it to the service.

    Implementation Pattern

    This project provides a base class for CRUD operations which can be extended for specific entities.

    • Base Class: sql-repository.base.ts provides generic CRUD logic.
    • Specific Implementation: Extend the base class to implement entity-specific queries (e.g., user.repository.ts).

    Note: While abstracting databases with interfaces is common, consider if the abstraction adds unnecessary complexity for your specific use case.

    // Example pattern: Extending a base repository
    // src/modules/user/database/user.repository.ts
    
    export class UserRepository extends SqlRepositoryBase<User> {
      async findByEmail(email: string): Promise<User | null> {
        // Specific implementation logic
      }
    }
  6. Organize code using Modules and Vertical Slicing

    master

    This project organizes code into Modules (also called components). Each module represents a cohesive business domain concept and should be treated as an independent, encapsulated mini-application.

    To implement Vertical Slicing, each business use case within a module is stored in its own dedicated folder. This groups together all files needed for that specific use case (e.g., commands, services, and queries), making it easier to manage changes that affect a single business process.

    Best Practices for Modules:

    • Encapsulation: Keep module internals private. Avoid direct imports between modules (e.g., import { X } from '../other-module').
    • Loose Coupling: Use a Mediator, a public Facade, or Message Passing (Commands/Events) to allow modules to cooperate without tight dependencies.
    • Small Size: Keep modules small and composable so they can be easily rewritten or extracted into microservices if requirements change.
  7. Make illegal states unrepresentable at runtime

    master

    Since user input and external data cannot be validated at compile time, you must use runtime validation to protect the domain.

    Strategies:

    1. DTO Validation: The first line of defense; filter incoming data at the application boundary.
    2. Domain Object Validation: Entities and Value Objects must protect their own invariants using techniques like Design by Contract (checking preconditions in constructors).
  8. How to design Domain Entities

    master

    Entities are the core of the domain, encapsulating business rules and attributes. They represent business models (e.g., User, Product) and must always protect their invariants (rules that must always be true for the entity to be valid).

    Implementation Guidelines:

    • Protect Invariants: Avoid public setters. Update state through explicit methods and execute validation (e.g., a validate() method) on every update.
    • Fail Fast: Validate all required properties during construction. An entity should not be able to exist in an invalid state.
    • Identity: Entities must have a consistent identity (usually an id field) that distinguishes them from others, regardless of attribute changes.
    • Equality: Equality is determined by comparing identifiers, not by comparing all properties.
    • Immutability: Make certain properties readonly (e.g., id, createdAt) to prevent accidental changes.
    • Avoid Anemic Models: Keep business logic inside the entities whenever possible rather than moving it all into services.
    • Constructor Pattern: Avoid no-arg constructors. Use a constructor that accepts and validates all required properties, or use a factory method like create().
  9. Implement Infrastructure Adapters

    master

    Infrastructure adapters (driven/secondary adapters) allow your system to interact with external technologies like databases, message brokers, or 3rd party APIs. They implement ports (interfaces) defined in the application or domain layers.

    Adapter Requirements

    To implement a proper adapter, ensure it includes:

    1. Port Implementation: It must implement an interface defined in the application/domain layer.
    2. Mapping: A mapper to convert data between the domain model and the external technology format.
    3. DTO/Interface: A contract for the data the adapter receives.
    4. Validation: A mechanism to ensure incoming data is not corrupted (e.g., via DTO decorators or Value Objects).

    Adapters can also serve as an Anti-Corruption Layer (ACL) to prevent legacy system logic from leaking into your new domain model.

  10. How Aggregates and Aggregate Roots work

    master

    An Aggregate is a cluster of domain objects (entities and value objects) treated as a single unit. It defines a Consistency Boundary where all invariants within the cluster must be satisfied after any operation.

    Key Components:

    • Aggregate Root: A specific entity within the aggregate that acts as the sole gateway to the entire cluster. All external references to the aggregate must point to the Root.
    • Identity: The Aggregate Root has a global identity (e.g., UUID). Entities inside the aggregate have local identities unique only within that aggregate.

    Rules for Usage:

    • Transactional Integrity: Any operation on an aggregate must be transactional; either the entire aggregate is updated/saved, or nothing is.
    • Access Pattern: Only Aggregate Roots should be retrieved directly via database queries. Other internal objects must be accessed through the Root.
    • Inter-Aggregate Communication: Objects within an aggregate should reference other Aggregate Roots via their global id rather than holding direct object references.
    • Size Management: Avoid overly large aggregates to prevent performance issues and maintenance complexity.
  11. Guidelines for using libraries in the Application Core

    master

    While injecting every library is not always practical, libraries used within the Application Core (especially the Domain layer) must be carefully selected to avoid leaking infrastructure concerns into business logic.

    Constraints for Core Libraries

    • No Out-of-Process Access: Libraries must not perform HTTP calls, database access, or other I/O.
    • No Domain-Irrelevant Logic: Avoid frameworks, ORMs, or loggers in the core.
    • No Randomness: Avoid libraries that generate random IDs or timestamps directly, as this makes domain logic non-deterministic and hard to test (use DI or mocks instead).
    • Low Volatility: Avoid libraries that change frequently or have heavy dependency trees.

    Mitigation Strategies

    • Anti-Corruption Layer: Use the Adapter or Facade patterns to wrap external libraries, ensuring the domain only interacts with an interface it controls.
    • Minimize Dependencies: The fewer dependencies the core has, the more robust and secure it becomes.
  12. Use DTOs to define API contracts

    master

    Data Transfer Objects (DTOs) define the contract between your API and its clients. They protect clients from internal data structure changes by providing a stable interface even when domain models or database schemas evolve.

    Types of DTOs

    • Request DTOs: Represent input data sent by a user. They ensure clients follow a specific contract to make correct requests.
    • Response DTOs: Represent output data returned to a user. They prevent data leaks by ensuring clients only receive explicitly defined properties (prefer whitelisting over blacklisting).

    Best Practices

    • Data-oriented: DTOs should be flat and use primitives rather than complex objects.
    • Validation: Use decorators like class-validator within DTO classes to handle input sanitization and validation.
    • Versioning: When breaking changes are necessary, version your API endpoints (e.g., v2/users) to maintain backward compatibility.
    • Separation from Commands: Do not use Commands as DTOs. Commands are serializable method calls for the domain model, while DTOs are external data contracts. Using DTOs prevents domain changes from breaking your public API.
    // Example concept: Request DTO with validation
    import { IsString, IsEmail } from 'class-validator';
    
    export class CreateUserRequestDto {
      @IsString()
      name: string;
    
      @IsEmail()
      email: string;
    }