A reference starter template implementing Clean Architecture principles with modular, vertical slice architecture for building scalable, maintainable, and testable TypeScript applications.
This is a structural reference template, not a production-ready boilerplate. It provides the architectural skeleton, base classes, conventions, and tooling configuration. You add the actual infrastructure, framework, and business logic for your project.
Universal Template: Designed for any JavaScript/TypeScript application - frontend, backend, or full-stack.
What's truly universal:
- ✅ Domain models (entities, value objects)
- ✅ Use cases (business workflows)
- ✅ Ports/Interfaces (repository contracts)
- ✅ Domain services (pure business logic)
What differs between frontend/backend:
- 🔄 View layer (UI components vs HTTP controllers)
- 🔄 Infrastructure (API clients vs database connections)
- 🔄 Error handling (Result types vs HTTP status codes)
- 🔄 Transaction management (backend only)
This template provides a solid foundation for TypeScript projects following Clean Architecture patterns with a modular approach. It enforces separation of concerns, dependency inversion, and testability from the ground up, making it suitable for both small and large-scale applications.
Key Advantage: Share domain models, use cases, and business logic between frontend and backend. Adapters and view layers are swappable based on your deployment target.
This template gives you the structural skeleton and conventions to build on:
- Universal Architecture: Same codebase structure for frontend and backend
- Modular Clean Architecture: Vertical slice architecture with domain modules
- Type Safety: Strict TypeScript configuration (
tsconfig.json) - Domain-Driven Design: Each module represents a bounded context
- Base Classes:
EntityandValueObjectbase classes (src/application/base/) - Tooling setup:
typecheck,formatscripts ready to use
- Infrastructure (database, HTTP client, cache — your stack)
- View layer (HTTP framework, UI framework — your choice)
- Domain modules (your business domain)
- Tests (your test runner)
This project follows Clean Architecture principles with a Vertical Slice Architecture approach to ensure maintainability, testability, and separation of concerns:
- Application Modules: Self-contained domain modules (bounded contexts)
- Domain Layer: Core business logic, entities, and domain rules
- Use Cases: Application-specific business logic
- Services: Orchestration layer (facades for Use Cases)
- Data Access: Repository implementations for the module
- Infrastructure Layer: External dependencies (database, HTTP clients, cache)
- View Layer: User interface and delivery mechanisms (HTTP, CLI, etc.)
Key Principles:
- Vertical Slices: Features are organized by domain module, not by technical layer
- Bounded Contexts: Each module is independent with its own domain model
- Dependency Inversion: High-level modules don't depend on low-level modules
- Separation of Concerns: Each layer has a specific, well-defined responsibility
- Testability: Business logic is isolated from external dependencies
- Domain-Centric: Business logic is independent of frameworks and external tools
📖 For detailed architecture information, see Architecture Deep Dive below
src/
├── application/ # Application Modules (Vertical Slices)
│ ├── base/ # Base classes for entities and value objects
│ │ ├── entity.ts
│ │ └── value-object.ts
│ │
│ └── {module}/ # Your domain modules (candidates, vacancies, applications, etc.)
│ ├── domain/
│ │ ├── entities/ # Business entities with behavior
│ │ ├── value-objects/ # Immutable domain values
│ │ ├── interfaces/ # Repository and service contracts
│ │ ├── services/ # Domain services (pure business logic)
│ │ ├── events/ # Domain events
│ │ └── errors/ # Domain-specific errors
│ ├── use-cases/ # Application workflows
│ ├── services/ # Application services (facades, orchestration)
│ └── data-access/ # Persistence adapters
│ ├── repositories/ # Repository implementations
│ ├── dto/ # DTOs for persistence layer
│ └── mappers/ # Entity ↔ DTO mappers
│
├── infrastructure/ # Infrastructure Layer — ILLUSTRATIVE structure, add what you need
│ ├── database/ # (example) Database connection
│ ├── http/ # (example) HTTP clients for external APIs
│ ├── cache/ # (example) Caching providers
│ ├── messaging/ # (example) Message queues
│ └── config/ # (example) Configuration management
│
├── view/ # View Layer — ILLUSTRATIVE, structure differs per project
│ #
│ # Frontend example:
│ │ ├── pages/ # Application pages/screens
│ │ ├── components/ # UI components
│ │ ├── hooks/ # UI hooks/composables
│ │ ├── stores/ # State management
│ │ └── dto/ + mappers/ # API DTOs and mappers
│ #
│ # Backend example:
│ │ ├── http/ # HTTP controllers, routes, middlewares
│ │ │ ├── controllers/
│ │ │ ├── dto/ # Request/Response DTOs
│ │ │ └── mappers/ # Entity ↔ HTTP DTO mappers
│ │ ├── cli/ # CLI commands
│ │ ├── graphql/ # GraphQL resolvers + types
│ │ └── websocket/ # WebSocket handlers
│
├── lib/ # Shared utilities (usable in all layers)
│ ├── utils/ # General utilities
│ ├── validators/ # Generic validators
│ ├── formatters/ # Data formatters
│ ├── parsers/ # Data parsers
│ └── types/ # Shared TypeScript types
│
├── app/ # Composition Root (wiring only)
│ └── composition-root/
│ ├── container.ts # DI container setup
│ └── setup.ts # Application bootstrap
│
└── index.ts # Application entry point
Each module represents a bounded context in DDD terminology and contains all layers needed for that specific domain:
Structure of Application Layer:
application/
├── base/ # Base classes (Entity, ValueObject)
│ ├── entity.ts
│ └── value-object.ts
│
└── {module}/ # Domain module
├── domain/ # Domain layer - pure business logic
│ ├── entities/ # Business entities with behavior
│ ├── value-objects/ # Immutable domain values
│ ├── interfaces/ # Repository and service contracts (ports)
│ ├── services/ # Domain services (pure business logic)
│ ├── events/ # Domain events
│ └── errors/ # Domain-specific exceptions
├── use-cases/ # Application workflows
├── services/ # Application services (facades, orchestration)
└── data-access/ # Persistence adapters
├── repositories/ # Repository implementations
├── dto/ # DTOs for persistence (database models)
└── mappers/ # Entity ↔ DTO conversion
Benefits:
- High cohesion within a module
- Low coupling between modules
- Easy to understand and navigate
- Independent testing; deployment independence depends on your packaging/deployment model
- Teams can work on different modules simultaneously
Entity Pattern:
All domain entities extend the base Entity class with typed structure:
// 1. Define the structure type
export type CandidateStruct = {
name: string;
email: Email;
phone?: Phone;
};
// 2. Extend Entity with ID type and Struct
export class Candidate extends Entity<UUID, CandidateStruct> {
// Access properties via this.props
updateEmail(newEmail: Email): void {
this.props.email = newEmail;
}
}See Base Classes section for more details.
Handles only external dependencies and technical implementations:
- Database connections and configuration
- External API clients (not domain repositories)
- Caching providers (Redis, Memcached)
- Message queue clients
- Configuration and environment management
Important distinction - Infrastructure vs Data Access:
The infrastructure/ layer provides shared technical resources (database client, HTTP client, cache connection), while application/{module}/data-access/ contains module-specific persistence adapters (repositories, DTOs, mappers).
Why this separation?
infrastructure/= Reusable infrastructure providers (database connection pool, Redis client)data-access/= Domain-specific adapters that use infrastructure providers- This follows Vertical Slice Architecture - keeping module cohesion while reusing technical infrastructure
Example (illustrative — add only what your project needs):
// src/infrastructure/database/postgres.connection.ts
export class PostgresConnection {
// Shared database connection pool — provided once, used by all data-access repositories
}
// src/application/candidates/data-access/repositories/candidate.repository.ts
export class PostgresCandidateRepository implements CandidateRepository {
constructor(private db: PostgresConnection) {} // Receives infrastructure via DI
async save(candidate: Candidate): Promise<void> {
// Module-specific persistence logic
}
}The ONLY layer that differs between frontend and backend applications.
Delivers data to and from users through various interfaces:
Frontend Applications:
- UI pages and components
- Hooks and composables
- State management
Backend Applications:
- HTTP REST API controllers and routes
- GraphQL resolvers
- CLI commands
- WebSocket handlers
CRITICAL RULE: View layer MUST call Application Services ONLY, NEVER Use Cases directly.
Everything else (application, infrastructure, lib) remains identical!
Contains pure, reusable utilities that can be used across all layers:
- String/Date/Array utilities
- Generic validators and formatters
- Cryptography helpers
- Observable pattern implementation
- Shared TypeScript types
Important: Lib has no dependencies on any layer and contains only framework-agnostic code.
View Layer
↓ calls
Application Services (Application Module)
↓ calls
Use Cases (Application Module)
↓ calls interfaces defined in ↘
Domain Layer ←── Data Access (implements domain interfaces)
↓ uses
Infrastructure (External Dependencies)
Read direction: Arrows show dependency direction (who depends on whom). Data Access depends on Domain (implements repository interfaces defined there) — Domain does NOT depend on Data Access. Infrastructure provides shared resources (DB connection, HTTP client) used by Data Access.
Dependency Rules:
- Domain has zero dependencies (except Lib) — no knowledge of Data Access or Infrastructure
- Exception: Aggregate Roots may import domain entities from other modules when they act as a transactional boundary (e.g., an
Applicationaggregate composingCandidate,Vacancy, andWorkflowStage). This is allowed only when the aggregate owns the business logic for those entity interactions.
- Exception: Aggregate Roots may import domain entities from other modules when they act as a transactional boundary (e.g., an
- Use Cases depend only on Domain interfaces
- Services depend on Use Cases (orchestration/facade layer)
- Data Access implements Domain repository interfaces (ports) — dependency points inward
- Infrastructure provides shared external resources used by Data Access
- View MUST call Application Services ONLY — View MUST NOT call Use Cases directly
- DTOs and Mappers live only at layer boundaries:
data-access/dto+data-access/mappersfor persistenceview/*/dto+view/*/mappersfor presentation
- Lib can be used by ALL layers (pure utilities only)
Instead of organizing code by technical layers (controllers, services, repositories), we organize by features/domains (modules). Each module contains all the layers it needs.
Traditional Layered Architecture:
src/
├── domain/ # All entities together
├── application/ # All use cases together
└── infrastructure/ # All repositories together
Problems:
- ❌ Low cohesion - related code is scattered
- ❌ High coupling - changes affect multiple folders
- ❌ Hard to navigate - need to jump between folders
- ❌ Difficult to scale - merge conflicts
Our Modular Architecture:
src/application/
├── candidates/ # Everything for candidates
│ ├── domain/
│ ├── use-cases/
│ └── data-access/
└── vacancies/ # Everything for vacancies
├── domain/
├── use-cases/
└── data-access/
Benefits:
- ✅ High cohesion - all candidate code is in
candidates/ - ✅ Low coupling -
candidates/andvacancies/are independent - ✅ Easy to navigate - one folder per domain
- ✅ Easy to scale - no merge conflicts between modules
- ✅ Team-friendly - different teams can work on different modules
Each module represents a bounded context in Domain-Driven Design:
- candidates - Candidate profile management
- vacancies - Job vacancy management
- applications - Candidate applications to vacancies (aggregate root)
- workflows - Hiring pipeline stages and transitions
- interviews - Interview scheduling and feedback
Modules communicate through:
- Application Service calls (sync, default)
- Domain Events via EventBus (async, optional — see Inter-Module Communication)
- Application Services return Entities; cross-module method parameters use primitives (string IDs) or DTOs when needed
- Exception: Aggregate Roots may compose domain entities from other modules as a transactional boundary
Create a new module when:
- ✅ You have a distinct business domain
- ✅ The domain has its own entities and rules
- ✅ It can be developed independently
- ✅ It could become a microservice
Don't create a new module when:
- ❌ It's just a utility function
- ❌ It's tightly coupled to an existing module
- ❌ It doesn't have its own business logic
Domain Layer:
- Pure unit tests
- No mocks
- Test business rules
Use Cases:
- Unit tests with mocked repositories
- Test application workflows
Data Access:
- Integration tests
- Real database (or test containers)
- Test mappings and queries
View (Frontend):
- Component tests
- Hook/Composable tests
- E2E tests
View (Backend):
- Controller unit tests
- API integration tests
- E2E tests for HTTP/GraphQL endpoints
This is an architecture template - it works with any modern Node.js version and package manager (npm, yarn, pnpm).
-
Clone or use this template:
git clone https://github.com/arkhipovdenis/typescript-clean-architecture.git cd typescript-clean-architecture -
Install dependencies (using your preferred package manager):
# Using pnpm (recommended) pnpm install # Or using npm npm install # Or using yarn yarn install
-
Verify the setup:
pnpm run typecheck # TypeScript type checking pnpm run format:check # Prettier formatting # or: npm run typecheck / yarn run typecheck
-
Start building your application:
- Add your domain modules in
src/application/ - Implement your view layer in
src/view/ - Add infrastructure adapters in
src/infrastructure/ - Wire everything in
src/app/composition-root/
- Add your domain modules in
This project uses strict TypeScript configuration for maximum type safety:
- Strict mode enabled (
strict: true— includesnoImplicitAny,strictNullChecks, and more) noUncheckedIndexedAccess— array/index access always includesundefinedexactOptionalPropertyTypes— optional properties must match exactlyverbatimModuleSyntax+isolatedModules— safe for bundlers and transpilersnoUnusedLocals/noUnusedParameters— available as opt-in (commented out intsconfig.json, enable when your project is ready)
When implementing a new feature domain, follow this structure:
-
Create module directory:
mkdir -p src/application/{module-name}/{domain,use-cases,services,data-access} mkdir -p src/application/{module-name}/domain/{entities,value-objects,interfaces,services,events,errors} mkdir -p src/application/{module-name}/data-access/{repositories,dto,mappers} -
Domain Layer: Define entities, value objects, and interfaces
// application/{module}/domain/entities/ // application/{module}/domain/value-objects/ // application/{module}/domain/interfaces/
-
Use Cases: Implement application logic
// application/{module}/use-cases/ -
Data Access: Implement repository interfaces
// application/{module}/data-access/ -
View: Create controllers/handlers
// view/http/controllers/{module}.controller.ts
Modules must be independent and loosely coupled. Here's how they should interact:
- Cross-module calls (sync): call the target module's Application Service directly; cross-module guards belong in the calling Application Service, not in use cases
- Shared types used by 3+ modules: use Shared Kernel — technical types only (
Money,Email,Result), no domain-specific concepts - Domain-specific IDs (
CandidateId): keep in their module, reference by primitive across boundaries - DI container: only in
src/app/composition-root/, NEVER inside domain or use cases - Domain Events + EventBus: optional — use when you need open/closed extensibility or eventual consistency (see section 2 below)
When: Module A cannot continue without a result from Module B.
How: Module B's Application Service is the public contract — its business methods define what the module exposes. Other modules import and call the service directly.
Rules:
- Other modules depend on the Application Service class of the target module, not its use cases or repositories
- Cross-module checks are the responsibility of the calling module's Application Service — not use cases
- Use cases know only their own domain; inter-module orchestration lives in Application Services
- Application Services return Entities, not DTOs — DTO mapping happens only at the view boundary
Example:
// 1. Module B: Application Service is the public API
// application/vacancies/services/vacancy.service.ts
export class VacancyService {
constructor(
private getVacancyUseCase: GetVacancyUseCase,
private checkVacancyStatusUseCase: CheckVacancyStatusUseCase
) {}
async getVacancyById(id: string): Promise<Vacancy> {
return this.getVacancyUseCase.execute({ id }); // ✅ Returns Entity
}
async checkVacancyOpen(vacancyId: string): Promise<boolean> {
return this.checkVacancyStatusUseCase.execute({ vacancyId });
}
}
// 2. Module A: Use case knows only its own domain
// application/applications/use-cases/create-application.use-case.ts
export class CreateApplicationUseCase {
constructor(
private applicationRepository: ApplicationRepository
// ✅ No dependency on VacancyService — use case stays within its module
) {}
async execute(dto: CreateApplicationDto): Promise<Application> {
const application = Application.create(dto);
await this.applicationRepository.save(application);
return application;
}
}
// 3. Module A: Application Service orchestrates cross-module check BEFORE calling use case
// application/applications/services/application.service.ts
export class ApplicationService {
constructor(
private createApplicationUseCase: CreateApplicationUseCase,
private vacancyService: VacancyService, // ✅ Cross-module call lives here
private logger: Logger
) {}
async createApplication(dto: CreateApplicationDto): Promise<Application> {
// Cross-module guard: check before executing the use case
const isOpen = await this.vacancyService.checkVacancyOpen(dto.vacancyId);
if (!isOpen) {
throw new ForbiddenError('Vacancy is closed for applications');
}
this.logger.info('Creating application', { vacancyId: dto.vacancyId });
return this.createApplicationUseCase.execute(dto); // ✅ Returns Entity
}
}
// 4. Composition root: Wire services
// src/app/composition-root/setup.ts
container.register(VacancyService, () =>
new VacancyService(
container.get(GetVacancyUseCase),
container.get(CheckVacancyStatusUseCase)
)
);
container.register(ApplicationService, () =>
new ApplicationService(
container.get(CreateApplicationUseCase),
container.get(VacancyService),
container.get(Logger)
)
);Project structure:
application/
├── vacancies/
│ ├── services/
│ │ └── vacancy.service.ts # Public API of the vacancies module
│ └── use-cases/ # Internal — not imported by other modules
│ └── get-vacancy.use-case.ts
└── applications/
├── services/
│ └── application.service.ts # Orchestrates: calls VacancyService + own use case
└── use-cases/
└── create-application.use-case.ts # Knows only applications domain
Domain Events are records of business-significant facts: something that happened in the domain.
// application/applications/domain/events/application-submitted.event.ts
export class ApplicationSubmittedEvent {
readonly occurredAt: Date;
constructor(
public readonly applicationId: string,
public readonly candidateId: string,
public readonly vacancyId: string
) {
this.occurredAt = new Date();
}
}In this template, Domain Events are a concept, not a required infrastructure pattern.
The default approach for cross-module reactions is a direct Application Service call (see section above). This is simpler, explicit, and sufficient for most projects.
When to add an EventBus (advanced/optional):
- You need to add new reactions without modifying the publishing code (open/closed principle)
- You need eventual consistency — a handler can fail and retry independently
- You are preparing for a transition to microservices
If you introduce an EventBus, use it as an infrastructure port (
EventBusinterface indomain/interfaces/), implement it ininfrastructure/, and wire handlers incomposition-root/. Keep domain event classes indomain/events/— they are pure data, no framework dependencies.
Only for technical types, not business entities.
STRICT CONSTRAINTS:
- Shared Kernel MUST contain ONLY technical types - No business logic or domain-specific concepts
- Types MUST be generic and domain-agnostic - If it's specific to one domain (Candidate, Vacancy), it doesn't belong here
- Shared Kernel MUST NOT contain:
- ❌ Domain entities with business logic
- ❌ Domain services
- ❌ Use cases
- ❌ Business rules or validations
- ❌ Domain-specific value objects (e.g.,
CandidateStatus,SalaryRangefor recruiting domain)
- Use Shared Kernel when: The type is used by 3+ modules AND is purely technical
Examples of what belongs in shared-kernel:
application/
└── shared-kernel/
├── value-objects/
│ ├── money.vo.ts # ✅ Technical VO used by multiple modules
│ ├── email.vo.ts # ✅ Technical VO
│ └── date-range.vo.ts # ✅ Technical VO
├── types/
│ ├── result.ts # ✅ Generic Result type
│ ├── pagination.ts # ✅ Generic pagination types
│ └── errors.ts # ✅ Base error classes
└── interfaces/
└── repository.interface.ts # ✅ Generic repository interface (optional)
What does NOT belong in shared-kernel:
❌ Candidate entity (business logic)
❌ Vacancy entity (business logic)
❌ Business rules/domain services
❌ Use cases
❌ CandidateId, VacancyId — domain-specific IDs owned by their module
❌ ApplicationStatus, SalaryRange — domain-specific value types
Common mistake: Moving
CandidateIdto shared-kernel because it's used in 2 modules. Correct approach: Use primitivestring/UUIDas the identifier type when crossing module boundaries, or use direct cross-module import only when tightly coupled and justified. Domain-specific types belong in their owning module's domain layer, not in shared-kernel.
❌ Forbidden:
// ❌ BAD: Importing from internal parts of another module
import { Vacancy } from '../../vacancies/domain/entities/vacancy.entity';
import { VacancyRepository } from '../../vacancies/domain/interfaces/vacancy-repository';
// ❌ BAD: Using DI container as service locator inside business code
class CreateApplicationUseCase {
async execute() {
const vacancyRepo = container.get(VacancyRepository); // ❌ Service locator anti-pattern
}
}
// ❌ BAD: Passing domain entities between modules
interface InterviewApi {
scheduleInterview(application: Application): Promise<void>; // ❌ Application is from another module
}✅ Correct:
// ✅ GOOD: Application Service calls another module's Application Service
import { VacancyService } from '../../vacancies/services/vacancy.service';
// ✅ GOOD: Cross-module dependency lives in Application Service, not use case
class ApplicationService {
constructor(
private createApplicationUseCase: CreateApplicationUseCase,
private vacancyService: VacancyService // ✅ Injected via constructor
) {}
}
// ✅ GOOD: Pass DTOs between modules
interface InterviewApi {
scheduleInterview(dto: ScheduleInterviewDto): Promise<void>; // ✅ DTO
}Sometimes one module genuinely depends on another from a business perspective. The default answer is a direct Application Service call. Direct domain imports are the exception, not the rule.
Shared Kernel is NOT the right place for
CandidateId.CandidateIdis a domain-specific concept owned by thecandidatesmodule. Moving it to shared-kernel would pollute the technical layer with business domain details.
// Option 1 (Default): Reference by primitive ID — no import needed
export type ApplicationStruct = {
candidateId: string; // ✅ Primitive ID, no cross-module dependency
vacancyId: string;
status: string;
};
export class Application extends Entity<UUID, ApplicationStruct> {}
// Option 2: Direct cross-module import — only when 2 modules are tightly coupled
// application/applications/domain/entities/application.entity.ts
import { CandidateId } from '../../candidates/domain/value-objects/candidate-id.vo';
export type ApplicationStruct = {
candidateId: CandidateId; // ⚠️ Explicit cross-module dependency — justify it
vacancyId: VacancyId;
status: string;
};
export class Application extends Entity<UUID, ApplicationStruct> {}When to use each:
- Primitive ID (string/UUID): Default — no coupling, passes by value
- Direct import: Only when 2 modules are tightly coupled AND it provides real type safety
- Shared Kernel: NEVER for domain-specific IDs. Only for
Money,Email,DateRange,Result,Pagination - Application Service call: Default for everything else
Special case: Aggregate Roots (DDD pattern)
Aggregates compose entities from multiple domains as transactional boundary:
// application/applications/domain/entities/application.entity.ts
import { Candidate } from '../../../candidates/domain/entities/candidate.entity';
import { Vacancy } from '../../../vacancies/domain/entities/vacancy.entity';
import { WorkflowStage } from '../../../workflows/domain/entities/workflow-stage.entity';
export type ApplicationStruct = {
candidate: Candidate;
vacancy: Vacancy;
stage: WorkflowStage;
};
/**
* Aggregate Root: Application represents hiring process
* Composes entities from multiple bounded contexts
*/
export class Application extends Entity<UUID, ApplicationStruct> {
moveToNextStage(): void {
// Business logic for stage transitions
if (!this.props.stage.canMoveNext(this.props.candidate)) {
throw new Error('Cannot move to next stage');
}
// ...
}
reject(reason: string): void {
// Business logic involving all composed entities
this.props.stage = WorkflowStage.rejected();
this.props.candidate.notifyRejection(this.props.vacancy, reason);
}
}When this is correct:
- ✅ Aggregate represents business process (Application = hiring workflow)
- ✅ Aggregate is transactional boundary (atomic changes)
- ✅ Contains business rules for entity interactions
- ✅ Single repository for entire aggregate
When this is wrong:
- ❌ Just "convenient" to keep together
- ❌ No business logic for interactions
- ❌ Can split into independent transactions
- ❌ Entities don't need consistency guarantees
Discouraged:
- Moving full business entities to shared-kernel (breaks bounded context)
Canonical location: src/app/composition-root/
What it does:
- Connects ports and adapters
- Wires all module dependencies together
- Registers Application Services with their cross-module dependencies
- Optionally: configures EventBus and registers event handlers (if using Domain Events)
What it MUST NOT do:
- ❌ MUST NOT be imported inside domain layer or use cases
- ❌ MUST NOT act as service locator (
container.get()inside business code)
Note: The
Containerclass below is pseudocode illustrating the concept. Use any DI library (InversifyJS, tsyringe, Awilix) or plain constructor injection in real projects. Lifetimes (transient/singleton/scoped) depend on your chosen library.
// src/app/composition-root/container.ts
// NOTE: Pseudocode illustrating the concept — replace with a real DI library.
// This simplified version is transient: factory is called on every get().
// Real libraries (InversifyJS, tsyringe, Awilix) support singleton/scoped/transient lifetimes.
export class Container {
private factories = new Map<symbol, () => unknown>();
// Registers a factory. For singleton behavior, cache the result yourself
// or use a DI library with built-in lifetime management.
register<T>(token: symbol, factory: () => T): void {
this.factories.set(token, factory);
}
get<T>(token: symbol): T {
const factory = this.factories.get(token);
if (!factory) throw new Error(`No registration for token`);
return factory() as T; // transient: new instance per call
}
}
// src/app/composition-root/setup.ts
export function setupContainer(): Container {
const container = new Container();
// Infrastructure
const db = new PostgresConnection(config.database);
// Repositories
container.register(CandidateRepository, () => new PostgresCandidateRepository(db));
container.register(ApplicationRepository, () => new PostgresApplicationRepository(db));
// Use cases — know only their own domain
container.register(SubmitApplicationUseCase, () =>
new SubmitApplicationUseCase(
container.get(ApplicationRepository)
)
);
// Application Services — cross-module wiring lives here
container.register(VacancyService, () =>
new VacancyService(
container.get(GetVacancyUseCase),
container.get(CheckVacancyStatusUseCase)
)
);
container.register(ApplicationService, () =>
new ApplicationService(
container.get(SubmitApplicationUseCase),
container.get(VacancyService) // ✅ Service-to-Service dependency
)
);
return container;
}| Scenario | Pattern | Why |
|---|---|---|
| Create application → check vacancy open | Application Service call | ApplicationService calls VacancyService before use case |
| Application → Interview needs data | Application Service call + DTO | ApplicationService calls InterviewService, returns DTO |
Share Money type |
Shared Kernel | Technical VO, no business logic |
| Application submitted → schedule interview | Application Service call (default) / Domain Event + EventBus (optional) | Direct call is simpler; EventBus if you need open/closed extensibility |
Summary:
- Application Service call = default for cross-module communication, explicit and simple
- Domain Events + EventBus = optional, for extensibility or eventual consistency
- Shared Kernel = minimal, only technical types
- DI = composition root only, never service locator
- Domain Layer: Pure unit tests with no mocks
- Use Cases: Unit tests with mocked repositories
- Data Access: Integration tests with real database
- View Layer (Frontend): Component tests, hook tests, E2E tests
- View Layer (Backend): Controller tests, API integration tests, E2E tests
The template includes base classes for entities and value objects:
import { Entity } from '../base/entity';
// Define the structure type
export type CandidateStruct = {
name: string;
email: Email;
phone?: Phone;
};
// Extend Entity with ID type and Struct type
export class Candidate extends Entity<UUID, CandidateStruct> {
// Business methods can access this.props
updateEmail(newEmail: Email): void {
this.props.email = newEmail;
}
get email(): Email {
return this.props.email;
}
}Another example with business logic:
export type VacancyStruct = {
title: string;
description: string;
salary: Money;
status: VacancyStatus;
};
export class Vacancy extends Entity<UUID, VacancyStruct> {
open(): void {
if (this.props.status === 'closed') {
this.props.status = 'open';
}
}
close(): void {
this.props.status = 'closed';
}
isOpen(): boolean {
return this.props.status === 'open';
}
}See application/base/entity.ts
import { ValueObject } from '../base/value-object';
class Email extends ValueObject<string> {
constructor(value: string) {
super(value);
}
}See application/base/value-object.ts
Each module should represent a single bounded context:
✓ Good:
application/user-authentication/
application/user-profile/
application/user-notifications/
✗ Bad:
application/users/ // Too broad, multiple concerns
Definition: Application Services are facades that orchestrate use cases and handle cross-cutting concerns. They are the public API of a module.
Definitions — three distinct layers:
| Layer | Responsibility | Lives in | Example |
|---|---|---|---|
| Domain Service | Pure business logic involving 2+ entities (no I/O) | domain/services/ |
SalaryCalculator — calculates offer salary from market data |
| Use Case | Single application workflow (one user action) | use-cases/ |
SubmitApplicationUseCase — validates + saves + returns entity |
| Application Service | Facade: orchestrates use cases + cross-cutting concerns | services/ |
CandidateService — entry point for the candidates module |
The "Services" arrow in
View → Services → Use Casesmeans Application Services, not Domain Services.
Application Service rules:
- ✅ Orchestrates multiple use cases for a single user-facing operation
- ✅ Handles cross-cutting concerns: logging, caching, authorization checks
- ✅ Acts as the public API of the module (what other modules or view layer calls)
- ✅ ALWAYS required — even if it's a thin wrapper around a single use case, at minimum add logging for traceability
- ❌ MUST NOT contain domain rules or business logic — that belongs in Domain or Use Case
When Application Service truly orchestrates (not just proxies):
// application/candidates/services/candidate.service.ts
export class CandidateService {
constructor(
private createCandidateUseCase: CreateCandidateUseCase,
private sendWelcomeEmailUseCase: SendWelcomeEmailUseCase,
private logger: Logger,
private cache: CandidateCache
) {}
// Orchestrates 2 use cases + logging + cache invalidation in one operation
async registerCandidate(dto: CreateCandidateDto): Promise<Candidate> {
this.logger.info('Registering candidate', { email: dto.email });
// Step 1: Create candidate (core use case)
const candidate = await this.createCandidateUseCase.execute(dto);
// Step 2: Trigger welcome email (second use case)
await this.sendWelcomeEmailUseCase.execute({ candidateId: candidate.id.value });
// Cross-cutting: invalidate cache
await this.cache.invalidateList();
this.logger.info('Candidate registered', { id: candidate.id.value });
return candidate; // ✅ Returns Entity — view layer maps to DTO
}
}View MUST call Application Services, NOT use cases directly:
// ❌ FORBIDDEN — View bypasses the module facade
class CandidatesController {
constructor(private createCandidateUseCase: CreateCandidateUseCase) {}
async create(req) { return this.createCandidateUseCase.execute(req.body); }
}
// ✅ REQUIRED — View calls the module's public facade
class CandidatesController {
constructor(private candidateService: CandidateService) {}
async create(req) { return this.candidateService.registerCandidate(req.body); }
}Why this rule?
- Single stable entry point per module — stable even when use cases are refactored
- Cross-cutting concerns (auth, logging, caching) applied once, not per use case
- Multiple delivery adapters (HTTP, CLI, WebSocket) all use the same service
- Testable in isolation: mock the service, not individual use cases in controller tests
// ✅ ApplicationService orchestrates cross-module calls — use case stays clean
class ApplicationService {
constructor(
private createApplicationUseCase: CreateApplicationUseCase,
private interviewService: InterviewService, // cross-module dependency
private notificationService: NotificationService
) {}
async submitApplication(dto: SubmitApplicationDto): Promise<Application> {
const application = await this.createApplicationUseCase.execute(dto);
// Cross-module reactions — explicit, traceable, no magic
await this.interviewService.scheduleInterview({
applicationId: application.id.value,
candidateId: application.candidateId, // ✅ primitive string
});
await this.notificationService.notifyCandidate({ candidateId: application.candidateId });
return application; // ✅ Returns Entity — view layer maps to DTO
}
}STRICT RULES:
- Use Cases MUST return Domain Entities — use cases work with rich domain models internally
- Application Services MUST return Domain Entities — mapping to DTO happens at the view boundary, not inside the application layer
- DTOs are allowed ONLY at these boundaries:
data-access/dto/— persistence layer (database rows ↔ entities)view/*/dto/— presentation layer (HTTP/GraphQL/WebSocket request+response)- Event payloads — minimal primitive data in event classes (if using EventBus)
- NEVER create DTOs at module level —
application/{module}/dto/is FORBIDDEN
Data flow:
View layer (maps Entity → DTO, returns DTO to client)
↑
Application Service (returns Entity)
↑
Use Case (returns Entity)
↑
Repository (maps DB row DTO → Entity)
Why these rules?
- Use cases work with rich domain models, not anemic data structures
- DTOs are translation objects only for crossing architectural boundaries
- Domain layer stays pure and framework-agnostic
// ✅ CORRECT: Use Cases return Entities
export class GetCandidateUseCase {
async execute(id: string): Promise<Candidate> {
return await this.repository.findById(id);
}
}
// ✅ CORRECT: View Layer has DTOs for HTTP/API
// view/http/dto/candidate-response.dto.ts
export interface CandidateResponseDto {
id: string;
name: string;
email: string;
}
// view/http/mappers/candidate.mapper.ts
export class CandidateViewMapper {
static toResponseDto(candidate: Candidate): CandidateResponseDto {
return {
id: candidate.id.value,
name: candidate.name,
email: candidate.email.value
};
}
}
// view/http/controllers/candidate.controller.ts
export class CandidateController {
constructor(private candidateService: CandidateService) {} // ✅ Service, not use case
async getCandidate(req, res) {
const candidate = await this.candidateService.getCandidate(req.params.id);
const dto = CandidateViewMapper.toResponseDto(candidate); // ✅ Map at boundary
res.json(dto);
}
}
// ✅ CORRECT: Data Access has DTOs for persistence
// application/candidates/data-access/dto/candidate-db.dto.ts
export interface CandidateDbDto {
id: string;
name: string;
email: string;
created_at: Date;
}
// application/candidates/data-access/mappers/candidate.mapper.ts
export class CandidatePersistenceMapper {
static toDomain(dto: CandidateDbDto): Candidate {
return Candidate.create(
CandidateId.create(dto.id),
{ name: dto.name, email: Email.create(dto.email) }
);
}
static toDto(candidate: Candidate): CandidateDbDto {
return {
id: candidate.id.value,
name: candidate.name,
email: candidate.email.value,
created_at: candidate.createdAt
};
}
}Key Rules:
- Use Cases work with Domain Entities only
- View Layer uses DTOs + Mappers for HTTP/GraphQL/WebSocket
- Data Access uses DTOs + Mappers for database/external APIs
- Never create DTOs at module level (
application/{module}/dto/)
Each aggregate root gets its own repository:
// ✓ Good
interface OrderRepository { }
interface CustomerRepository { }
// ✗ Bad
interface GenericRepository<T> { }Repository implementations should focus only on persistence:
// ✓ Good
class PostgresUserRepository implements UserRepository {
async save(user: User): Promise<void> {
// Just save
}
}
// ✗ Bad
class PostgresUserRepository implements UserRepository {
async save(user: User): Promise<void> {
// Save + send email + update cache + ...
}
}These patterns violate Clean Architecture principles and will break modularity:
❌ FORBIDDEN:
// application/candidates/use-cases/create-candidate.use-case.ts
class CreateCandidateUseCase {
async execute(dto: CreateCandidateDto) {
// ❌ Using container.get() inside use case
const repo = container.get(CandidateRepository);
const emailService = container.get(EmailService);
}
}✅ CORRECT:
// application/candidates/use-cases/create-candidate.use-case.ts
class CreateCandidateUseCase {
constructor(
private candidateRepository: CandidateRepository, // ✅ Constructor injection
private emailService: EmailService
) {}
async execute(dto: CreateCandidateDto) {
// Use injected dependencies
}
}Why: DI container should only be used in composition root, not as service locator in business code.
❌ FORBIDDEN:
// application/interviews/use-cases/schedule-interview.use-case.ts
import { Candidate } from '../../candidates/domain/entities/candidate.entity';
import { Vacancy } from '../../vacancies/domain/entities/vacancy.entity';
// ❌ Importing domain entities from other modules without proper justification
class ScheduleInterviewUseCase {
async execute(candidate: Candidate, vacancy: Vacancy) {
// ...
}
}✅ CORRECT - Use Application Service call from the calling Service:
// application/interviews/services/interview.service.ts
import { CandidateService } from '../../candidates/services/candidate.service';
class InterviewService {
constructor(
private scheduleInterviewUseCase: ScheduleInterviewUseCase,
private candidateService: CandidateService // ✅ Service-to-Service, not use case
) {}
async scheduleInterview(dto: ScheduleInterviewDto) {
const candidate = await this.candidateService.getCandidate(dto.candidateId);
// cross-module guard / enrichment here, then:
return this.scheduleInterviewUseCase.execute(dto);
}
}Exception: Only allowed for Aggregate Roots that genuinely compose multiple domain entities as a transactional boundary.
If you introduce an EventBus, do not use events as RPC.
❌ FORBIDDEN:
// ❌ Using events to request data (like RPC)
const result = await eventBus.publish(new GetCandidateDataEvent(candidateId));
console.log(result.candidateEmail); // ❌ Events don't return data✅ CORRECT — call the Application Service directly:
// ✅ Synchronous data retrieval via Application Service call
const candidateData = await this.candidateService.getCandidate(candidateId);
console.log(candidateData.email);Why: Domain Events are fire-and-forget notifications. For request/response, call the Application Service directly.
❌ FORBIDDEN:
// lib/validate-candidate.ts
export function validateCandidate(candidate: Candidate): boolean {
// ❌ Business logic with domain concepts in lib/
return candidate.age >= 18 && candidate.hasRequiredSkills();
}✅ CORRECT - Business logic in Domain:
// application/candidates/domain/entities/candidate.entity.ts
export class Candidate extends Entity<UUID, CandidateStruct> {
// ✅ Business logic inside domain entity
isEligible(): boolean {
return this.age >= 18 && this.hasRequiredSkills();
}
}Why: lib/ contains ONLY pure utilities with no business logic or I/O operations.
❌ FORBIDDEN:
// view/http/controllers/candidates.controller.ts
class CandidatesController {
constructor(private createCandidateUseCase: CreateCandidateUseCase) {} // ❌
async create(req, res) {
const candidate = await this.createCandidateUseCase.execute(req.body); // ❌
return res.json(candidate);
}
}✅ CORRECT - Call Application Services, map to DTO in view:
// view/http/controllers/candidates.controller.ts
class CandidatesController {
constructor(private candidateService: CandidateService) {} // ✅
async create(req, res) {
const candidate = await this.candidateService.createCandidate(req.body); // ✅ Entity
return res.status(201).json(CandidateViewMapper.toResponseDto(candidate)); // ✅ Map at view boundary
}
}Why: Application Services provide a stable facade, handle cross-cutting concerns, and allow refactoring use cases without breaking view layer.
❌ FORBIDDEN:
// application/candidates/dto/candidate.dto.ts ← ❌ WRONG LOCATION
export interface CandidateDto {
name: string;
email: string;
}✅ CORRECT - DTOs only at boundaries:
// application/candidates/data-access/dto/candidate-persistence.dto.ts ← ✅ For persistence
// view/http/dto/candidate-response.dto.ts ← ✅ For HTTP layerWhy: DTOs are translation objects for crossing architectural boundaries, not for internal module use.
❌ FORBIDDEN:
// application/shared-kernel/domain-services/candidate-validator.ts
export class CandidateValidator {
// ❌ Business logic specific to recruiting domain
validateCandidateEligibility(candidate: Candidate): boolean {
return candidate.yearsOfExperience >= 2;
}
}✅ CORRECT - Domain-specific logic stays in module:
// application/candidates/domain/services/candidate-validator.service.ts
export class CandidateValidator {
// ✅ Business logic in its own bounded context
validateEligibility(candidate: Candidate): boolean {
return candidate.yearsOfExperience >= 2;
}
}Why: Shared Kernel is for technical types only (Money, Email, Result), NOT domain-specific business logic.
The following remains 100% identical between frontend and backend:
- ✅ Application Modules (
application/) - Domain, use cases, services - ✅ Lib (
lib/) - Pure utilities, validators, formatters - ✅ Business Logic - All domain entities, value objects, and use cases
- ✅ Architecture Patterns - Clean Architecture, DDD, Vertical Slices
The view/ and infrastructure/ layers differ:
View Layer:
- Frontend: UI components, pages, hooks, state management
- Backend: HTTP controllers, GraphQL resolvers, CLI commands
Infrastructure Layer:
- Frontend: HTTP clients for APIs, browser storage, auth providers
- Backend: Database connections, message queues, cache servers, email services
Frontend (view/):
view/
├── pages/ # Application pages/screens
├── components/ # UI components
├── hooks/ # UI hooks/composables
└── stores/ # State managementBackend (view/):
view/
├── http/
│ ├── controllers/ # HTTP request handlers
│ ├── routes/ # Route definitions
│ └── middlewares/ # HTTP middlewares
├── cli/ # CLI commands
├── graphql/ # GraphQL resolvers
└── websocket/ # WebSocket handlersUse Case (Identical for Both):
// application/candidates/use-cases/create-candidate.use-case.ts
export class CreateCandidateUseCase {
constructor(private candidateRepository: CandidateRepository) {}
async execute(dto: CreateCandidateDto): Promise<Candidate> { // ✅ Returns Entity
const email = Email.create(dto.email);
const candidate = Candidate.create(email, dto.name);
await this.candidateRepository.save(candidate);
return candidate; // ✅ Return entity, not DTO
}
}Frontend View (example with React):
// view/hooks/useCreateCandidate.ts
import { container } from '../../app/composition-root/container';
import { CandidateService } from '../../application/candidates/services/candidate.service';
export function useCreateCandidate() {
const candidateService = container.get(CandidateService);
return useMutation({
mutationFn: (dto) => candidateService.createCandidate(dto)
});
}
// view/pages/CandidatePage.tsx
const createCandidate = useCreateCandidate();
createCandidate.mutate({ email, name });Backend View (example with Node.js):
// view/http/controllers/candidate.controller.ts
export class CandidateController {
constructor(private candidateService: CandidateService) {} // ✅ Through Service
async create(req: Request, res: Response) {
const candidate = await this.candidateService.createCandidate(req.body); // ✅ Entity
res.status(201).json({ success: true, data: CandidateViewMapper.toResponseDto(candidate) }); // ✅ Map at view boundary
}
}Result: Same business logic, different delivery mechanisms!
If you have an existing layered architecture project:
- Create module directories
- Move domain entities to
application/{module}/domain/entities/ - Move use cases to
application/{module}/use-cases/ - Move repository implementations to
application/{module}/data-access/ - Keep external dependencies in
infrastructure/
MIT License - see LICENSE file for details.
Built with Clean Architecture and Vertical Slice Architecture principles for maintainable and scalable TypeScript applications.