Skip to content

About

A modular Clean Architecture + Domain-Driven Design reference implementation for React and TypeScript.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

React Clean Architecture

A modular Clean Architecture + Domain-Driven Design reference implementation for React and TypeScript.

This project demonstrates how to build a frontend application where domain logic, application logic, infrastructure, transport, and presentation remain independently replaceable.

It is designed as both:

  • πŸ“š A reference for learning Clean Architecture and DDD in TypeScript
  • πŸ—οΈ A starting point for building production applications
  • πŸ§ͺ An example of how to keep application logic highly testable
  • πŸ”Œ A demonstration of replaceable persistence and message transports
  • 🧩 A modular architecture that can grow with the business

The goal is not to create the largest possible abstraction stack.

The goal is to establish clear boundaries so that the application can evolve without forcing unrelated parts of the system to evolve with it.


✨ Highlights

  • 🧠 Domain-driven design
  • πŸ›οΈ Clean Architecture
  • 🧩 Feature/module-oriented organization
  • πŸ”„ CQRS with commands and queries
  • 🚌 Replaceable message buses
  • πŸ’Ύ Persistence abstraction with IndexedDB
  • 🌐 Browser/server infrastructure separation
  • πŸ—ƒοΈ Application-owned persistence ports
  • 🧱 Entities and Value Objects
  • πŸ“¦ DTOs and primitive representations
  • ⚑ Application-level caching
  • 🏷️ Tag-based cache invalidation
  • πŸ”Œ Replaceable infrastructure implementations
  • πŸ§ͺ Architecture designed for unit and integration testing
  • πŸ”’ Strict TypeScript
  • βš›οΈ React + React Router
  • 🐳 Docker support

πŸ“– Table of Contents


🧠 Philosophy

Clean Architecture is not primarily about folders.

It is about dependency direction.

The most important rule in this project is:

Inner layers must not depend on outer layers.

The domain should not know that React exists.

The application should not know that IndexedDB exists.

A use case should not know whether it is running in a browser, a server, a worker, or another environment.

Infrastructure adapts external technologies to the abstractions required by the application.

The composition root puts everything together.


πŸ›οΈ Architecture

At a high level:

flowchart TD
  UI["React<br/>Presentation"]

  APP["Application<br/><br/>Commands<br/>Queries<br/>Handlers<br/>DTOs<br/>Ports"]

  DOMAIN["Domain<br/><br/>Entities<br/>Value Objects<br/>Domain rules"]

  PORTS["Ports<br/><br/>DAO<br/>Cache<br/>Other adapters"]

  INFRA["Infrastructure<br/><br/>IndexedDB<br/>Browser APIs<br/>Server APIs<br/>External systems"]

  ROOT["Composition Root<br/><br/>Creates and wires<br/>the application"]

  UI --> APP
  APP --> DOMAIN
  APP --> PORTS
  PORTS -->|"implemented by"| INFRA

  ROOT -.->|"creates & wires"| UI
  ROOT -.->|"creates & wires"| APP
  ROOT -.->|"creates & wires"| INFRA
Loading

The dependency direction is therefore:

flowchart TD
  PRESENTATION["Presentation"]
  APPLICATION["Application"]
  DOMAIN["Domain"]
  INFRASTRUCTURE["Infrastructure"]
  PORTS["Application Ports"]

  PRESENTATION --> APPLICATION
  APPLICATION --> DOMAIN
  INFRASTRUCTURE --> PORTS
Loading

Infrastructure points inward by implementing interfaces owned by the application.


πŸ” Dependency Rule

The architecture can be summarized with this matrix:

Layer Domain Application Infrastructure React
Domain βœ… ❌ ❌ ❌
Application βœ… βœ… ❌ ❌
Infrastructure βœ… βœ… βœ… ❌
Presentation ❌ βœ… ❌ βœ…

The important part is not the directory names.

The important part is that the dependency graph follows these rules.

For example:

// Application
export interface EntityDao {
  count(): Promise<number>;
}

Infrastructure implements it:

// Infrastructure
export class EntityIndexedDbDao implements EntityDao {
  async count(): Promise<number> {
    // IndexedDB implementation
  }
}

The application depends on the abstraction:

flowchart TD
  APPLICATION["Application"]
  DAO["EntityDao"]
  INFRASTRUCTURE["Infrastructure"]

  APPLICATION --> DAO
  INFRASTRUCTURE -->|"implements"| DAO
Loading

It does not depend on EntityIndexedDbDao.


πŸ“ Project Structure

The repository is organized primarily by module, with architectural layers inside each module.

.
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ bootstrap/
β”‚   β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ contexts/
β”‚   β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ layouts/
β”‚   β”œβ”€β”€ pages/
β”‚   β”œβ”€β”€ providers/
β”‚   └── root.tsx
β”‚
β”œβ”€β”€ di/
β”‚   β”œβ”€β”€ container.browser.ts
β”‚   β”œβ”€β”€ container.server.ts
β”‚   └── types.ts
β”‚
β”œβ”€β”€ modules/
β”‚   β”œβ”€β”€ common/
β”‚   β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”œβ”€β”€ state/
β”‚   β”‚   └── users/
β”‚   β”‚
β”‚   β”œβ”€β”€ shared/
β”‚   β”‚   β”œβ”€β”€ cookies/
β”‚   β”‚   β”œβ”€β”€ data/
β”‚   β”‚   β”œβ”€β”€ jwt/
β”‚   β”‚   └── value-objects/
β”‚   β”‚
β”‚   └── demo/
β”‚       β”œβ”€β”€ entities/
β”‚       β”œβ”€β”€ use-cases/
β”‚       β”œβ”€β”€ infrastructures/
β”‚       └── overview/
β”‚
β”œβ”€β”€ public/
β”‚
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ package.json
β”œβ”€β”€ pnpm-lock.yaml
β”œβ”€β”€ pnpm-workspace.yaml
β”œβ”€β”€ react-router.config.ts
β”œβ”€β”€ tsconfig.json
└── vite.config.ts

🧩 Modules

The application is module-oriented, rather than globally organized by technical layer.

Instead of:

domain/
application/
infrastructure/

the project groups related business concepts:

modules/
  entities/
  use-cases/
  infrastructures/
  overview/

Each module can then contain its own:

domain/
application/
infrastructure/

This keeps related functionality together and makes the architecture easier to navigate as the system grows.


🧠 Domain Layer

The domain layer contains business concepts and rules.

Typical domain objects include:

  • Entities
  • Value Objects
  • Aggregates
  • Domain services
  • Domain errors
  • Domain rules

The domain should be independent of:

  • React
  • IndexedDB
  • HTTP
  • Browser APIs
  • Database implementations
  • UI components
  • Transport protocols

For example:

export class EntityEntity extends Entity<
  IdValue,
  EntityPrimitives
> {
  constructor(
    id: IdValue,
    readonly type: TypeValue,
    readonly name: NameValue,
    readonly description: DescriptionValue,
    readonly fields: FieldsValue,
  ) {
    super(id);
  }
}

The domain model expresses the concept itself rather than how the concept is stored or displayed.


πŸ’Ž Value Objects

Value Objects are used when a value has domain meaning and invariants.

For example:

const name = new NameValue("Customer");

instead of:

const name = "Customer";

This allows the value object to enforce its own rules.

For example:

NameValue
   β”‚
   β”œβ”€β”€ validation
   β”œβ”€β”€ normalization
   └── representation

Value Objects are particularly useful for:

  • IDs
  • Names
  • Types
  • Descriptions
  • Structured collections
  • Domain-specific values

βš™οΈ Application Layer

The application layer coordinates the execution of business operations.

It contains things such as:

application/
β”œβ”€β”€ commands/
β”œβ”€β”€ queries/
β”œβ”€β”€ command-handlers/
β”œβ”€β”€ query-handlers/
β”œβ”€β”€ dtos/
└── interfaces/

The application layer answers:

What does the system need to do?

while the domain answers:

What are the business rules?

and infrastructure answers:

How do we technically do it?


πŸ”Œ Application Ports

Application code owns the abstractions it needs.

For example:

export interface EntityDao {
  count(): Promise<number>;
  getAll(): Promise<EntityEntity[]>;
}

The interface belongs to the application because the application requires that capability.

Infrastructure implements it:

export class EntityIndexedDbDao
  implements EntityDao
{
  // IndexedDB implementation
}

This means persistence can later change without modifying the application:

flowchart LR
  DAO["EntityDao"]

  DAO --> IDB["IndexedDB"]
  DAO --> REST["REST API"]
  DAO --> SQLITE["SQLite"]
  DAO --> MEMORY["In-memory"]
Loading

πŸ”„ CQRS

The project uses CQRS to separate state-changing operations from read operations.

flowchart TD
  APPLICATION["Application"]

  COMMANDS["Commands"]
  QUERIES["Queries"]

  COMMAND_HANDLERS["Command Handlers"]
  QUERY_HANDLERS["Query Handlers"]

  MUTATIONS["Mutations"]
  READS["Reads"]

  APPLICATION --> COMMANDS
  APPLICATION --> QUERIES

  COMMANDS --> COMMAND_HANDLERS
  QUERIES --> QUERY_HANDLERS

  COMMAND_HANDLERS --> MUTATIONS
  QUERY_HANDLERS --> READS
Loading

Commands

Commands represent an intention to change state.

Examples:

CreateEntity
UpdateEntity
DeleteEntity
CreateUseCase
UpdateInfrastructure

Commands should describe what the user/system wants to happen, rather than how it should happen.


Queries

Queries retrieve information without changing application state.

Examples:

GetEntity
GetAllEntities
GetOverview
GetAllUseCases
GetAllInfrastructures

A query handler typically:

  1. Receives a query
  2. Reads through application ports
  3. Builds an application DTO
  4. Returns the result

🚌 Message Buses

Commands and queries are executed through buses.

The application can work with different bus implementations:

flowchart TD
  MESSAGE["Message"]
  BUS["Bus"]

  IN_MEMORY["In-Memory<br/>Bus"]
  REMOTE["Remote<br/>Bus"]

  HANDLER_MEMORY["Handler"]
  SERIALIZATION["Serialization"]
  TRANSPORT["Transport"]
  DESERIALIZATION["Deserialization"]
  HANDLER_REMOTE["Handler"]

  MESSAGE --> BUS

  BUS --> IN_MEMORY
  BUS --> REMOTE

  IN_MEMORY --> HANDLER_MEMORY

  REMOTE --> SERIALIZATION
  SERIALIZATION --> TRANSPORT
  TRANSPORT --> DESERIALIZATION
  DESERIALIZATION --> HANDLER_REMOTE
Loading

The current application can use an in-memory bus because queries and commands execute inside the same process.

A remote implementation can serialize messages when communication with another process or system is required.


πŸ“¦ Serialization

Serialization is a transport concern.

An in-memory message does not need to be serialized:

flowchart TD
  QUERY["Query"]
  BUS["InMemoryQueryBus"]
  HANDLER["QueryHandler"]

  QUERY --> BUS
  BUS --> HANDLER
Loading

A remote message may require:

flowchart TD
  QUERY["Query"]
  SERIALIZE["Serialize"]
  TRANSPORT["Transport"]
  DESERIALIZE["Deserialize"]
  HANDLER["QueryHandler"]

  QUERY --> SERIALIZE
  SERIALIZE --> TRANSPORT
  TRANSPORT --> DESERIALIZE
  DESERIALIZE --> HANDLER
Loading

This allows the same application-level messages to be used with different transport mechanisms.


⚑ Caching

Queries can be cached at the application level.

The purpose is to keep caching behavior close to the operation being cached rather than forcing the presentation layer to understand cache internals.

Conceptually:

flowchart TD
  QUERY["Query"]
  HANDLER["Query Handler"]
  HIT["Cache hit"]
  CACHED["Return cached result"]
  MISS["Cache miss"]
  EXECUTE["Execute"]
  STORE["Store result"]
  DTO["Return DTO"]

  QUERY --> HANDLER
  HANDLER --> HIT
  HANDLER --> MISS

  HIT --> CACHED

  MISS --> EXECUTE
  EXECUTE --> STORE
  STORE --> DTO
Loading

🏷️ Tag-Based Cache Invalidation

Commands can invalidate cached queries through tags.

For example:

GetAllEntities
    tags:
      entities

A mutation:

CreateEntity
    invalidates:
      entities

can automatically invalidate related cached results.

This avoids coupling React components to persistence or cache management.

The conceptual flow is:

flowchart TD
  CREATE["CreateEntity"]
  MUTATION["Mutation"]
  INVALIDATE["Invalidate tags"]
  ENTITIES["Entities"]

  GET_ALL["GetAllEntities<br/>cached"]
  GET_LIST["GetEntityList<br/>cached"]

  INVALID["Invalid"]

  CREATE --> MUTATION
  MUTATION --> INVALIDATE
  INVALIDATE --> ENTITIES

  ENTITIES --> GET_ALL
  ENTITIES --> GET_LIST

  GET_ALL --> INVALID
  GET_LIST --> INVALID
Loading

This becomes particularly useful as the number of queries grows.


πŸ’Ύ Persistence

The current example uses IndexedDB.

Persistence is hidden behind application-owned interfaces.

flowchart TD
  APPLICATION["Application"]
  DAO["EntityDao"]
  IMPLEMENTATION["EntityIndexedDbDao"]
  INDEXEDDB["IndexedDB"]

  APPLICATION --> DAO
  IMPLEMENTATION -->|"implements"| DAO
  IMPLEMENTATION --> INDEXEDDB
Loading

The application does not need to know:

  • object store names
  • IndexedDB transactions
  • browser database APIs
  • persistence schemas
  • database-specific implementation details

🧱 Persistence Schemas vs Domain Objects

Persistence models and domain models are intentionally separate.

For example:

flowchart TD
  SCHEMA["IndexedDB Schema"]
  DAO["DAO"]
  ENTITY["Domain Entity"]
  DTO["Application DTO"]

  SCHEMA --> DAO
  DAO --> ENTITY
  ENTITY --> DTO
Loading

A database schema can therefore contain persistence-specific properties without forcing those properties into the domain model.

This allows persistence concerns to evolve independently.


πŸ“¦ DTOs and Primitives

Different boundaries have different representations.

A simplified flow is:

flowchart TD
  SCHEMA["Persistence Schema"]
  PRIMITIVES["Domain Primitives"]
  ENTITY["Domain Entity"]
  DTO["Application DTO"]
  PRESENTATION["Presentation"]

  SCHEMA --> PRIMITIVES
  PRIMITIVES --> ENTITY
  ENTITY --> DTO
  DTO --> PRESENTATION
Loading

For example:

entity.toPrimitives()

can produce a representation suitable for application-level serialization.

The important rule is:

A persistence representation does not have to be the domain model.


πŸ”§ Dependency Injection

The composition root is responsible for wiring implementations together.

Conceptually:

flowchart TD
  ROOT["Composition Root"]

  DAO["EntityDao"]
  CACHE["Cache"]
  LOGGER["Logger"]

  IDB["IndexedDbDao"]

  ROOT --> DAO
  ROOT --> CACHE
  ROOT --> LOGGER

  IDB -->|"implements"| DAO
Loading

This keeps construction decisions outside the domain and application logic.

For example:

EntityDao
    β”‚
    └── EntityIndexedDbDao

can later become:

EntityDao
    β”‚
    β”œβ”€β”€ EntityIndexedDbDao
    β”œβ”€β”€ EntityApiDao
    β”œβ”€β”€ EntityMemoryDao
    └── EntityMockDao

without changing the application code that consumes EntityDao.


🌐 Browser and Server

The application supports different infrastructure implementations for different environments.

For example:

flowchart TD
  AUTH["AuthProvider"]

  BROWSER_AUTH["Browser Auth"]
  SERVER_AUTH["Server Auth"]

  BROWSER_API["Browser APIs"]
  SERVER_API["Request / Server APIs"]

  AUTH --> BROWSER_AUTH
  AUTH --> SERVER_AUTH

  BROWSER_AUTH --> BROWSER_API
  SERVER_AUTH --> SERVER_API
Loading

The application depends on the abstraction:

AuthProvider

while the composition root chooses the environment-specific implementation.

This allows the same application concepts to operate in different runtimes.


βš›οΈ React / Presentation

React belongs to the outermost presentation layer.

Components should not directly manipulate:

IndexedDB
JWT implementation
DAO implementations
database schemas
domain persistence

Instead, the presentation layer invokes application operations.

Conceptually:

flowchart TD
  COMPONENT["React Component"]
  REQUEST["Application Query / Command"]
  BUS["Bus"]
  HANDLER["Handler"]
  DOMAIN["Domain / Ports"]

  COMPONENT --> REQUEST
  REQUEST --> BUS
  BUS --> HANDLER
  HANDLER --> DOMAIN
Loading

This keeps UI code focused on presentation.


🧭 Adding a New Feature

When adding a new business capability, start from the domain concept rather than the technology.

For example, suppose we want to add Products.

A possible structure is:

modules/
└── products/
    β”œβ”€β”€ domain/
    β”‚   β”œβ”€β”€ entities/
    β”‚   β”œβ”€β”€ value-objects/
    β”‚   └── errors/
    β”‚
    β”œβ”€β”€ application/
    β”‚   β”œβ”€β”€ commands/
    β”‚   β”œβ”€β”€ queries/
    β”‚   β”œβ”€β”€ command-handlers/
    β”‚   β”œβ”€β”€ query-handlers/
    β”‚   β”œβ”€β”€ dtos/
    β”‚   └── interfaces/
    β”‚
    └── infrastructure/
        β”œβ”€β”€ database/
        β”‚   β”œβ”€β”€ schemas/
        β”‚   └── dao/
        └── ...

πŸ› οΈ Example: Adding a Product Query

Define the query:

export class GetProductQuery {
  constructor(
    readonly id: string,
  ) {}
}

Define the application port:

export interface ProductDao {
  getById(id: string): Promise<ProductEntity | null>;
}

Implement the port:

export class ProductIndexedDbDao
  implements ProductDao
{
  async getById(id: string) {
    // IndexedDB implementation
  }
}

Create the handler:

export class GetProductQueryHandler {
  constructor(
    private readonly productDao: ProductDao,
  ) {}

  async execute(
    query: GetProductQuery,
  ): Promise<ProductDto | null> {
    const product =
      await this.productDao.getById(query.id);

    if (!product) {
      return null;
    }

    return product.toPrimitives();
  }
}

Register the handler with the query bus:

queryBus.register(
  GetProductQuery,
  getProductQueryHandler,
);

Then React only needs to execute the query:

const product = await queryBus.execute(
  new GetProductQuery(productId),
);

React doesn't need to know how the product is persisted.


πŸ§ͺ Testing

The architecture is intentionally designed so that each layer can be tested independently.

Domain tests

Test:

Entities
Value Objects
Domain rules
Domain errors

without React, IndexedDB, or external services.


Application tests

.env.example Application services and handlers can use fake ports:

const productDao = new FakeProductDao();

const handler =
  new GetProductQueryHandler(productDao);

const result = await handler.execute(
  new GetProductQuery("product-id"),
);

No database is required.


Infrastructure tests

Infrastructure implementations can be tested against their actual technology.

For example:

flowchart TD
  DAO["ProductIndexedDbDao"]
  DB["IndexedDB"]

  DAO --> DB
Loading

These tests verify that the adapter correctly translates between the persistence model and the application/domain model.


Architecture tests

The project should also enforce architectural boundaries.

Examples:

Domain
  ❌ React
  ❌ IndexedDB
  ❌ Browser APIs
  ❌ Infrastructure

Application
  ❌ React
  ❌ Infrastructure implementations
  ❌ Browser APIs

Infrastructure
  βœ… Implements application ports

Presentation
  βœ… Uses application layer

Architecture tests are especially valuable because they prevent accidental dependency violations as the project grows.


🧱 Architecture Rules

When contributing code, keep these rules in mind.

Rule 1 β€” Business logic belongs in the domain

If something represents a business invariant, it should not live inside a React component.


Rule 2 β€” Application coordinates use cases

The application layer orchestrates operations.

It should not know how those operations are technically implemented.


Rule 3 β€” Infrastructure implements ports

Infrastructure adapts technologies to application-owned abstractions.

flowchart LR
  PORT["Port"]
  ADAPTER["Adapter"]

  ADAPTER -->|"implements"| PORT
Loading

Rule 4 β€” React is an outer layer

React should not become the place where business rules live.


Rule 5 β€” Don't create abstractions without a reason

A repository, service, factory, aggregate, adapter, or interface should exist because it solves a real architectural or domain problem.

Avoid abstraction for abstraction's sake.


Rule 6 β€” Model the business, not the architecture

Prefer:

orders
products
inventory
payments

when those are actual business concepts.

Architectural patterns should support the domain rather than dictate it.


Rule 7 β€” Share domain concepts intentionally

A concept should belong to common only when it is intentionally shared across bounded contexts.

Technical utilities without domain meaning belong in shared.


πŸ”„ Example Dependency Flow

Consider creating an entity:

flowchart TD
  REACT["React"]

  COMMAND["CreateEntityCommand"]
  BUS["Command Bus"]
  HANDLER["CreateEntityCommandHandler"]

  DAO["EntityDao"]
  ADAPTER["IndexedDB Adapter"]

  DOMAIN["Entity Domain Model"]
  VALIDATION["Domain Validation"]
  PERSISTENCE["Persistence"]

  REACT -->|"CreateEntityCommand"| COMMAND
  COMMAND --> BUS
  BUS --> HANDLER

  HANDLER --> DAO
  DAO --> ADAPTER

  HANDLER --> DOMAIN
  DOMAIN --> VALIDATION
  VALIDATION --> PERSISTENCE
Loading

At no point does the domain need to know:

React
IndexedDB
HTTP
Browser APIs

🌍 Replaceable Infrastructure

One of the benefits of the architecture is that infrastructure can change without rewriting application logic.

For example:

flowchart TD
  DAO["EntityDao"]

  INDEXEDDB["IndexedDB"]
  REST["REST API"]
  MEMORY["In-Memory"]

  DAO --> INDEXEDDB
  DAO --> REST
  DAO --> MEMORY
Loading

The application continues to depend on:

EntityDao

rather than the implementation.

This is particularly useful for:

  • testing
  • migrations
  • SSR
  • offline applications
  • different deployment environments
  • future backend integration

πŸš€ Getting Started

Requirements

  • Node.js
  • pnpm

Install dependencies:

pnpm install

Start development:

pnpm dev

Type-check the project:

pnpm typecheck

Build:

pnpm build

Run the production build:

pnpm start

🐳 Docker

The project also includes a Dockerfile.

Build the image:

docker build -t react-clean-architecture .

Run it:

docker run -p 3000:3000 react-clean-architecture

🧰 Technology Stack

Technology Purpose
React UI
React Router Routing
TypeScript Type safety
Vite Build tooling
Tailwind CSS Styling
IndexedDB Browser persistence
Inversify Dependency injection
pnpm Package management
Docker Containerization

πŸ“š Architectural Concepts Demonstrated

This repository intentionally demonstrates several concepts together:

Clean Architecture

Dependency direction and separation of concerns.

Domain-Driven Design

Business-oriented modules, entities, value objects, and domain boundaries.

CQRS

Separating commands from queries.

Dependency Inversion

Application-owned ports implemented by infrastructure.

Hexagonal Architecture

Infrastructure adapters surrounding application/domain ports.

DTOs

Keeping application-facing data separate from domain objects.

Persistence Mapping

Keeping database schemas independent from domain models.

Message Transport Abstraction

Allowing in-process and serialized/remote messages.

Cache Invalidation

Application-level caching with tag-based invalidation.


🧭 Clean Architecture vs DDD

These concepts are related, but they solve different problems.

Clean Architecture primarily answers:

How should dependencies be organized?

DDD primarily answers:

How should software represent and evolve with the business domain?

This project uses both.

A simplified view:

flowchart TD
  DDD["DDD"]

  DOMAIN_MODEL["Domain Model"]
  BOUNDED_CONTEXTS["Bounded Contexts"]

  CLEAN["Clean Architecture"]

  DOMAIN["Domain"]
  APPLICATION["Application"]
  INFRASTRUCTURE["Infrastructure"]

  DDD --> DOMAIN_MODEL
  DDD --> BOUNDED_CONTEXTS

  DOMAIN_MODEL --> CLEAN
  BOUNDED_CONTEXTS --> CLEAN

  CLEAN --> DOMAIN
  CLEAN --> APPLICATION
  CLEAN --> INFRASTRUCTURE
Loading

The architecture should serve the domain rather than become the domain.


🧠 Design Principles

The project follows these general principles:

High cohesion

Keep related concepts together.

Low coupling

Avoid unnecessary dependencies between modules.

Dependency inversion

Depend on abstractions owned by the consuming layer.

Explicit boundaries

Make architectural boundaries visible in the codebase.

Replaceability

Infrastructure should be replaceable without rewriting application logic.

Testability

Business and application logic should be testable without requiring external systems.

Business language

Domain concepts should reflect the language of the business.

Pragmatism

Do not introduce a pattern merely because a pattern exists.


🀝 Contributing

Contributions are welcome.

Before opening a pull request:

  1. Read the architecture rules.
  2. Keep dependencies pointing inward.
  3. Avoid introducing infrastructure concerns into domain code.
  4. Keep application ports in the application layer.
  5. Add tests for new behavior.
  6. Keep business logic out of React components.
  7. Prefer simple solutions over unnecessary abstractions.
  8. Update documentation when introducing architectural changes.

Pull Requests

A good pull request should explain:

What changed?

Briefly describe the feature or fix.

Why?

Explain the problem being solved.

Architectural impact

If applicable, explain:

  • new module boundaries
  • new ports
  • new infrastructure adapters
  • new commands/queries
  • changes to domain rules
  • changes to transport
  • changes to caching

Testing

Explain what was tested.

For example:

- Added domain tests
- Added query handler tests
- Added IndexedDB integration tests
- Added serialization tests

🧹 Code Style

Prefer explicit, intention-revealing code.

Good:

const product =
  await productDao.getById(productId);

Avoid hiding meaningful behavior behind unnecessary abstractions:

const result =
  await someGenericOperationResolver.execute(...);

unless the abstraction solves a real problem.


πŸ—οΈ Adding New Infrastructure

When adding an external technology:

  1. Define the capability required by the application.
  2. Create an application-owned port if necessary.
  3. Implement the port in infrastructure.
  4. Wire the implementation in the composition root.
  5. Keep the technology-specific details outside the application/domain.

Example:

flowchart TD
  APPLICATION["Application"]
  GATEWAY["PaymentGateway"]
  STRIPE["StripePaymentGateway"]

  APPLICATION --> GATEWAY
  STRIPE -->|"implements"| GATEWAY
Loading

The application knows about:

PaymentGateway

but not:

StripePaymentGateway

πŸ—ΊοΈ Roadmap

The project is continuously evolving.

Potential areas of development include:

  • Comprehensive domain tests
  • Application unit tests
  • Infrastructure integration tests
  • Architecture tests
  • Command examples
  • Remote message bus example
  • Serialization/deserialization examples
  • More persistence adapters
  • More complex aggregates
  • Domain events
  • Event-driven examples
  • Better architecture documentation
  • Example bounded contexts
  • More complete testing examples

The goal is to evolve the project through real architectural requirements rather than adding patterns simply for demonstration purposes.


⭐ Why This Architecture?

A frontend application can become difficult to maintain when:

React Component
    β”‚
    β”œβ”€β”€ API call
    β”œβ”€β”€ validation
    β”œβ”€β”€ business rules
    β”œβ”€β”€ local storage
    β”œβ”€β”€ cache
    β”œβ”€β”€ database
    └── state management

all live together.

This architecture separates those concerns:

flowchart TD
  UI["UI"]
  APPLICATION["Application"]
  DOMAIN["Domain"]
  PORTS["Ports"]
  INFRASTRUCTURE["Infrastructure"]

  UI --> APPLICATION

  APPLICATION --> DOMAIN
  APPLICATION --> PORTS

  INFRASTRUCTURE -->|"implements"| PORTS
Loading

The result is not fewer files.

The result is fewer reasons for unrelated parts of the system to change together.


πŸ“Œ Final Principle

The most important rule in this repository is simple:

Architecture exists to make change cheaper.

If changing IndexedDB requires rewriting the domain, the architecture failed.

If changing React requires rewriting the business rules, the architecture failed.

If adding a new transport requires rewriting every use case, the architecture failed.

If changing a business rule only requires changing the domain/application code that owns that rule, the architecture is doing its job.


About

A modular Clean Architecture + Domain-Driven Design reference implementation for React and TypeScript.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages