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.
- π§ 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
- Philosophy
- Architecture
- Dependency Rule
- Project Structure
- Modules
- Domain Layer
- Application Layer
- Infrastructure Layer
- CQRS
- Commands and Queries
- Message Buses
- Serialization
- Caching
- Persistence
- DTOs and Primitives
- Dependency Injection
- Browser and Server
- React / Presentation
- Adding a New Feature
- Example
- Testing
- Architecture Rules
- Development
- Docker
- Contributing
- Design Principles
- What This Project Is Not
- Roadmap
- License
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.
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
The dependency direction is therefore:
flowchart TD
PRESENTATION["Presentation"]
APPLICATION["Application"]
DOMAIN["Domain"]
INFRASTRUCTURE["Infrastructure"]
PORTS["Application Ports"]
PRESENTATION --> APPLICATION
APPLICATION --> DOMAIN
INFRASTRUCTURE --> PORTS
Infrastructure points inward by implementing interfaces owned by the application.
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
It does not depend on EntityIndexedDbDao.
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
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.
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 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
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 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"]
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
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 retrieve information without changing application state.
Examples:
GetEntity
GetAllEntities
GetOverview
GetAllUseCases
GetAllInfrastructures
A query handler typically:
- Receives a query
- Reads through application ports
- Builds an application DTO
- Returns the result
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
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 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
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
This allows the same application-level messages to be used with different transport mechanisms.
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
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
This becomes particularly useful as the number of queries grows.
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
The application does not need to know:
- object store names
- IndexedDB transactions
- browser database APIs
- persistence schemas
- database-specific implementation details
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
A database schema can therefore contain persistence-specific properties without forcing those properties into the domain model.
This allows persistence concerns to evolve independently.
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
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.
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
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.
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
The application depends on the abstraction:
AuthProviderwhile the composition root chooses the environment-specific implementation.
This allows the same application concepts to operate in different runtimes.
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
This keeps UI code focused on presentation.
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/
βββ ...
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.
The architecture is intentionally designed so that each layer can be tested independently.
Test:
Entities
Value Objects
Domain rules
Domain errors
without React, IndexedDB, or external services.
.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 implementations can be tested against their actual technology.
For example:
flowchart TD
DAO["ProductIndexedDbDao"]
DB["IndexedDB"]
DAO --> DB
These tests verify that the adapter correctly translates between the persistence model and the application/domain model.
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.
When contributing code, keep these rules in mind.
If something represents a business invariant, it should not live inside a React component.
The application layer orchestrates operations.
It should not know how those operations are technically implemented.
Infrastructure adapts technologies to application-owned abstractions.
flowchart LR
PORT["Port"]
ADAPTER["Adapter"]
ADAPTER -->|"implements"| PORT
React should not become the place where business rules live.
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.
Prefer:
orders
products
inventory
payments
when those are actual business concepts.
Architectural patterns should support the domain rather than dictate it.
A concept should belong to common only when it is intentionally shared across bounded contexts.
Technical utilities without domain meaning belong in shared.
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
At no point does the domain need to know:
React
IndexedDB
HTTP
Browser APIs
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
The application continues to depend on:
EntityDaorather than the implementation.
This is particularly useful for:
- testing
- migrations
- SSR
- offline applications
- different deployment environments
- future backend integration
- Node.js
- pnpm
Install dependencies:
pnpm installStart development:
pnpm devType-check the project:
pnpm typecheckBuild:
pnpm buildRun the production build:
pnpm startThe 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 | 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 |
This repository intentionally demonstrates several concepts together:
Dependency direction and separation of concerns.
Business-oriented modules, entities, value objects, and domain boundaries.
Separating commands from queries.
Application-owned ports implemented by infrastructure.
Infrastructure adapters surrounding application/domain ports.
Keeping application-facing data separate from domain objects.
Keeping database schemas independent from domain models.
Allowing in-process and serialized/remote messages.
Application-level caching with tag-based invalidation.
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
The architecture should serve the domain rather than become the domain.
The project follows these general principles:
Keep related concepts together.
Avoid unnecessary dependencies between modules.
Depend on abstractions owned by the consuming layer.
Make architectural boundaries visible in the codebase.
Infrastructure should be replaceable without rewriting application logic.
Business and application logic should be testable without requiring external systems.
Domain concepts should reflect the language of the business.
Do not introduce a pattern merely because a pattern exists.
Contributions are welcome.
Before opening a pull request:
- Read the architecture rules.
- Keep dependencies pointing inward.
- Avoid introducing infrastructure concerns into domain code.
- Keep application ports in the application layer.
- Add tests for new behavior.
- Keep business logic out of React components.
- Prefer simple solutions over unnecessary abstractions.
- Update documentation when introducing architectural changes.
A good pull request should explain:
Briefly describe the feature or fix.
Explain the problem being solved.
If applicable, explain:
- new module boundaries
- new ports
- new infrastructure adapters
- new commands/queries
- changes to domain rules
- changes to transport
- changes to caching
Explain what was tested.
For example:
- Added domain tests
- Added query handler tests
- Added IndexedDB integration tests
- Added serialization tests
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.
When adding an external technology:
- Define the capability required by the application.
- Create an application-owned port if necessary.
- Implement the port in infrastructure.
- Wire the implementation in the composition root.
- Keep the technology-specific details outside the application/domain.
Example:
flowchart TD
APPLICATION["Application"]
GATEWAY["PaymentGateway"]
STRIPE["StripePaymentGateway"]
APPLICATION --> GATEWAY
STRIPE -->|"implements"| GATEWAY
The application knows about:
PaymentGatewaybut not:
StripePaymentGatewayThe 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.
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
The result is not fewer files.
The result is fewer reasons for unrelated parts of the system to change together.
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.