Domain-Driven Design at Parallel
Table of Contents
- Resources
- Introduction
- Architecture Overview
- Layer Structure
- Key Patterns
- Error Management
- Folder Structure
- The Golden Use-Case (Command Pattern)
- The Second Golden Use-Case (Query Pattern)
- Best Practices
- Common Pitfalls
Resources
- Full DDD Documentation: Domain-Driven Design at Parallel (Notion)
- Golden Use Case References:
- Command Pattern:
services/coding/src/application-layer/optimization/evaluate/ - Query/Projection Pattern:
services/coding/src/application-layer/projection/medical-stays-table/
- Command Pattern:
Introduction
At Parallel, we implement Domain-Driven Design (DDD) using Hexagonal Architecture (also known as Ports & Adapters). This approach ensures our business logic remains independent from external concerns like frameworks, databases, and APIs.
Core Principles
- Domain at the center: Business logic is isolated and framework-agnostic
- Dependency inversion: High-level modules don't depend on low-level modules
- Testability: Pure business logic can be tested without infrastructure
- Maintainability: Clear separation of concerns makes code easier to understand and modify
Architecture Overview
Our architecture follows the Hexagonal pattern with three main layers:
┌─────────────────────────────────────────────────────────┐
│ Infrastructure Layer (Adapters) │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│ │ API │ │ Persist. │ │ External │ │
│ │Controllers│ │ Prisma │ │ Services │ │
│ └────┬──────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
├─────────────────────────────────────────────────────────┤
│ Application Layer (Use Cases) │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Use Cases (Orchestration) │ │
│ │ - Inject Dependencies (Ports) │ │
│ │ - Coordinate Domain & Infrastructure │ │
│ └───────────────────┬──────────────────────┘ │
│ │ │
│ ▼ │
├─────────────────────────────────────────────────────────┤
│ Domain Layer (Core Business) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Aggregates│ │ Entities │ │ Value │ │
│ │ Events │ │ Errors │ │ Objects │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘Flow Direction
- Request enters via Infrastructure (Controller)
- Use case orchestrates the business operation
- Domain logic executes (aggregates, entities)
- Infrastructure persists changes via repositories
- Response returns through Infrastructure (DTOs, Mappers)
Layer Structure
1. Domain Layer (domain-layer/)
Purpose: Pure business logic, framework-agnostic
Contains:
- Aggregates (
*.aggregate.ts): Consistency boundaries with business invariants - Entities (
*.entity.ts): Objects with identity - Value Objects (
*.value-object.ts): Immutable objects without identity - Domain Events (
*.events.ts): Things that happened in the domain - Domain Errors (
*.errors.ts): Business rule violations
Rules:
- ✅ NO external dependencies (no NestJS, no Prisma, no HTTP)
- ✅ NO infrastructure imports (no repositories, no services)
- ✅ Pure TypeScript/JavaScript classes
- ✅ All properties MUST be private with public getters
- ✅ Business logic encapsulated in methods
- ✅ Emit domain events for significant state changes
Example Structure:
export class Optimization {
public events: OptimizationEvent<unknown>[] = []
private readonly _id: string
private _workingCoding: Coding
private _optimizationGain: number | null
constructor(props: IOptimizationConstructor) {
this._id = props.id
// ... initialization
}
get id(): string {
return this._id
}
// Business logic method
evaluate(params: {...}): void {
this._workingCoding.group(params)
this.computeOptimizationGain()
this._updatedAt = new Date()
// Emit domain event
this.events.push(new OptimizationEvaluatedEvent({...}))
}
// Private business logic
private computeOptimizationGain(): void {
// Pure business calculation
}
}2. Application Layer (application-layer/)
Purpose: Use cases that orchestrate domain logic and infrastructure
Contains:
- Use Cases (
use-case.ts): Single application operation - Use Case Modules (
use-case.module.ts): NestJS module for DI - Dependencies/Ports (
dependencies/): Interfaces for infrastructure
Structure per Use Case:
application-layer/
└── aggregateName/
├── dependencies/ # Ports (interfaces)
│ ├── aggregate.repository.ts
│ └── external-service.provider.ts
├── use-case-name/
│ ├── use-case-name.use-case.ts
│ ├── use-case-name.use-case.module.ts
│ └── index.ts
└── index.tsDependency Interface Pattern:
// dependencies/optimization.repository.ts
export const OPTIMIZATION_REPOSITORY = Symbol('OPTIMIZATION_REPOSITORY');
export interface OptimizationRepository {
newId(): string;
getOrThrow(id: string): Promise<Optimization>;
update(optimization: Optimization): Promise<void>;
}Use Case Pattern:
@Injectable()
export class EvaluateOptimizationUseCase implements IUseCase {
constructor(
@Inject(OPTIMIZATION_REPOSITORY)
private readonly optimizationRepository: OptimizationRepository,
@Inject(VALUATION_PROVIDER)
private readonly valuationProvider: ValuationProvider,
private readonly loggerService: CustomLoggerService,
) {}
async execute(params: {...}): Promise<Result> {
// 1. Fetch domain aggregate
const optimization = await this.optimizationRepository.getOrThrow(params.id)
// 2. Call external services if needed
const valuation = await this.valuationProvider.groupAndPrice({...})
// 3. Execute domain logic
optimization.evaluate(valuation)
// 4. Persist changes
await this.optimizationRepository.update(optimization)
// 5. Publish events
await this.eventProducer.sendMany({
events: optimization.events
})
// 6. Return result
return {...}
}
}Rules:
- ✅ Use dependency injection via interfaces (ports)
- ✅ One use case = one business operation
- ✅ Orchestrate but don't contain business logic
- ✅ Always fetch full aggregates (not partial data)
- ✅ Persist through repositories, not direct DB access
- ✅ Publish domain events after persistence
3. Infrastructure Layer (infrastructure-layer/)
Purpose: Adapters that connect to external systems
Contains:
3.1 API (api/)
HTTP/WebSocket interface to the application
Structure:
api/
├── controllers/
│ └── aggregate-name/
│ └── operation-name/
│ ├── operation-name.controller.ts
│ ├── operation-name.controller.module.ts
│ ├── operation-name.controller.dto.ts
│ ├── operation-name.controller.mapper.ts
│ └── index.ts
├── auth/ # Authentication
└── context/ # Request context
└── socket/ # WebsocketController Pattern:
@ApiTags('Optimization')
@Controller('optimization')
export class OptimizationController {
constructor(
private readonly evaluateOptimizationUseCase: EvaluateOptimizationUseCase,
) {}
@Post('/:id/evaluate')
@ApiOperation({ summary: 'Evaluate optimization' })
@ApiResponse({ status: 200, type: EvaluateOptimizationOutputDto })
async evaluateOptimization(
@Param() params: OptimizationIdParamDto,
@Body() body: EvaluateOptimizationBodyDto,
@User('tenantId') tenantId: string,
): Promise<EvaluateOptimizationOutputDto> {
// 1. Call use case
const result = await this.evaluateOptimizationUseCase.execute({
optimizationId: params.id,
shouldEvaluateReference: body.shouldEvaluateReference,
tenantId,
});
// 2. Map to DTO
return EvaluateOptimizationMapper.toDto(result);
}
}DTO Pattern:
export class EvaluateOptimizationBodyDto {
@ApiProperty({
description: 'Should evaluate reference',
type: Boolean,
example: false,
})
@IsBoolean()
shouldEvaluateReference!: boolean;
}
export class EvaluateOptimizationOutputDto {
@ApiProperty({
description: 'GHM (Groupe Homogène de Malades)',
type: String,
nullable: true,
})
GHM!: string | null;
@ApiProperty({
description: 'Price of the medical grouping',
type: Number,
nullable: true,
})
price!: number | null;
}Mapper Pattern:
export const EvaluateOptimizationMapper = {
toDto: (
result: EvaluateOptimizationResult,
): EvaluateOptimizationOutputDto => {
return {
GHM: result.ghm,
GHS: result.ghs,
price: result.price,
error: result.groupingError
? {
code: result.groupingError.code ?? 0,
description: result.groupingError.description ?? '',
}
: null,
};
},
};3.2 Persistence (persistence/)
Database adapters (Prisma repositories)
Structure:
persistence/
└── prisma/
├── client/
├── repositories/
│ └── prisma-aggregate-name.repository.ts
├── mappers/
│ └── aggregate-name.mapper.ts
└── prisma-repositories.module.tsRepository Implementation Pattern:
@Injectable()
export class PrismaOptimizationRepository implements OptimizationRepository {
constructor(
private readonly codingPrismaService: CodingPrismaService,
private readonly customLoggerService: CustomLoggerService,
) {}
async getOrThrow(optimizationId: string): Promise<Optimization> {
// 1. Fetch from DB
const prismaData = await this.getPrismaDataOrThrow(optimizationId);
// 2. Map to domain
const optimization = OptimizationMapper.toDomain(prismaData);
return optimization;
}
async update(optimization: Optimization): Promise<void> {
// 1. Map to persistence
const persistenceData = OptimizationMapper.toPersistence(optimization);
// 2. Update DB
await this.codingPrismaService.defaultInstance.$transaction(async (tx) => {
await tx.medicalStayCoding.update({
where: { id: optimization.id },
data: persistenceData.medicalStayCodingWorkingUpdate,
});
});
}
}Persistence Mapper Pattern:
export const OptimizationMapper = {
toDomain: (params: {
prismaMedicalStay: PrismaMedicalStay;
prismaMedicalStayCoding: PrismaMedicalStayCoding;
}): Optimization => {
return new Optimization({
id: params.prismaMedicalStayCoding.id,
tenantId: params.prismaMedicalStay.tenantId,
// Map all properties
});
},
toPersistence: (
optimization: Optimization,
): {
medicalStayCodingWorkingUpdate: Prisma.MedicalStayCodingUpdateInput;
} => {
return {
medicalStayCodingWorkingUpdate: {
// Map aggregate state to DB schema
},
};
},
};3.3 External Services (external-services/)
Adapters to external APIs
Pattern:
@Injectable()
export class ParallelValuationProvider implements ValuationProvider {
async groupAndPrice(params: { coding: Coding }): Promise<ValuationResult> {
// Call external API and map response
}
}3.4 Consumers (consumers/)
Event consumers (BullMQ jobs, queue processors)
3.5 Events (events/)
Event producers (Socket.io, message queues)
Key Patterns
1. Aggregate Pattern
- Root entity that ensures consistency boundary
- All operations go through the aggregate
- Contains business invariants
- Emits domain events
2. Repository Pattern
- Abstraction over data persistence
- Defined as interface in application layer
- Implemented in infrastructure layer
- Works with full aggregates, not partial data
3. Dependency Inversion
// ✅ GOOD: Application defines interface
// application-layer/dependencies/repository.ts
export interface OptimizationRepository {
getOrThrow(id: string): Promise<Optimization>;
}
// Infrastructure implements interface
// infrastructure-layer/persistence/repositories/repository.ts
export class PrismaOptimizationRepository implements OptimizationRepository {
async getOrThrow(id: string): Promise<Optimization> {}
}4. Domain Events
// Domain emits events
export class Optimization {
evaluate(params: {...}): void {
// Business logic
this.events.push(new OptimizationEvaluatedEvent({...}))
}
}
// Use case publishes events
async execute(params: {...}) {
optimization.evaluate({...})
await this.repository.update(optimization)
await this.eventProducer.sendMany({ events: optimization.events })
}5. Factory Methods
// Static factory methods on aggregates
export class Optimization {
static startFromReference(params: {...}): Optimization {
const workingCoding = new Coding({...})
return new Optimization({...})
}
}Error Management
Overview
Errors flow from Domain → Application → Infrastructure, where they're automatically handled by infrastructure adapters. This keeps domain logic pure while providing consistent error handling.
Domain (throw) → Application (bubble) → Infrastructure (catch & handle)
├─ API: Map to HTTP responses
└─ Consumers: Log & emit metrics1. Domain Errors (domain-layer/)
Define business rule violations as domain errors:
// domain-layer/medical-stay/medical-stay.errors.ts
export class MedicalStayUnauthorizedError extends DomainError {
constructor(
public readonly operation: string,
public readonly user: IUser,
public readonly allowedUserPermission: UserPermission,
public readonly medicalStayId: string,
) {
super(`User '${user.userId}' is not authorized to ${operation}`);
}
}
export class MedicalStayInvalidStatusError extends DomainError {
constructor(
public readonly operation: string,
public readonly status: MedicalStayStatus,
public readonly allowedStatuses: MedicalStayStatus[],
public readonly medicalStayId: string,
) {
super(`Status '${status}' is not valid for ${operation}`);
}
}Rules:
- ✅ Extend
DomainErrorbase class - ✅ Include context as public readonly properties (for logging/metrics)
- ✅ Provide clear error messages
- ✅ NO HTTP concepts (status codes, responses)
2. Application Layer (Use Cases)
Let domain errors bubble up naturally:
// application-layer/medical-stay/validate-coding/validate-coding.use-case.ts
@Injectable()
export class ValidateCodingUseCase {
async execute(params: {...}): Promise<Result> {
// Domain throws error if business rule violated
const medicalStay = await this.repository.getOrThrow(params.id)
medicalStay.validateCoding(params.user) // May throw MedicalStayUnauthorizedError
await this.repository.update(medicalStay)
return {...}
}
}Rules:
- ✅ Let domain errors propagate (no try-catch unless adding context)
- ✅ Focus on orchestration, not error handling
- ❌ DON'T catch domain errors just to re-throw them
3. Infrastructure Layer (API)
Automatically catch and map domain errors to HTTP responses:
// infrastructure-layer/api/filters/domain-error.filter.ts
@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
constructor(
private readonly logger: CustomLoggerService,
private readonly metrics: DatadogMetricsProvider,
) {}
catch(exception: DomainError, host: ArgumentsHost) {
const { status, error, message } = this.mapDomainErrorToHttpError(exception)
response.status(status).json({
statusCode: status,
error,
message,
timestamp: new Date().toISOString(),
})
}
private mapDomainErrorToHttpError(exception: DomainError) {
if (exception instanceof MedicalStayUnauthorizedError) {
this.logger.error(exception.message, exception.stack, {...})
return {
status: HttpStatus.FORBIDDEN,
error: 'Forbidden',
message: `You don't have permission to ${exception.operation}`,
}
}
if (exception instanceof MedicalStayInvalidStatusError) {
this.metrics.increment(ErrorMetricsEnum.INVALID_STATUS_ERROR, 1)
return {
status: HttpStatus.BAD_REQUEST,
error: 'Bad Request',
message: `Cannot ${exception.operation} with status ${exception.status}`,
}
}
// ... other mappings
}
}Register globally in app.module.ts:
@Module({
providers: [
{
provide: APP_FILTER,
useClass: DomainErrorFilter,
},
],
})
export class AppModule {}4. Controllers Stay Clean
No try-catch needed - filter handles everything:
@Controller('medical-stays')
export class MedicalStayController {
@Post('/:id/validate-coding')
async validateCoding(
@Param() params: MedicalStayIdParamDto,
@User() user: IUser,
): Promise<void> {
// No error handling needed!
// If domain throws MedicalStayUnauthorizedError → automatic 403
// If domain throws MedicalStayInvalidStatusError → automatic 400
await this.validateCodingUseCase.execute({
medicalStayId: params.id,
user,
});
}
}5. Consumers (BullMQ) - Same Pattern
Consumers also catch and handle domain errors automatically:
// infrastructure-layer/consumers/domain-consumer-error/domain-consumer-error.handler.ts
@Injectable()
export class DomainConsumerErrorHandler
implements BaseDomainConsumerErrorHandler
{
constructor(
private readonly logger: CustomLoggerService,
private readonly metrics: DatadogMetricProvider,
) {}
handle(error: DomainError, job: Job): void {
if (error instanceof MedicalStayUnauthorizedError) {
this.logger.error(
`Unauthorized access in consumer: ${error.message}`,
error.stack,
{
jobId: job.id,
queueName: job.queueName,
context: {
medicalStayId: error.medicalStayId,
operation: error.operation,
},
},
);
return;
}
if (error instanceof MedicalStayInvalidStatusError) {
this.logger.error(
`Invalid status in consumer: ${error.message}`,
error.stack,
{
jobId: job.id,
context: {
status: error.status,
allowedStatuses: error.allowedStatuses,
},
},
);
this.metrics.increment(DomainErrorMetric.INVALID_STATUS_ERROR, 1);
return;
}
// ... other mappings
}
}Register globally in app.module.ts:
@Global()
@Module({
providers: [
{
provide: DOMAIN_CONSUMER_ERROR_HANDLER,
useClass: DomainConsumerErrorHandler,
},
],
})
export class DomainConsumerErrorHandlerModule {}Consumers stay clean - no try-catch needed:
@Processor(BullMqQueues.CODING_MEDICAL_STAY_GROUP_QUEUE)
export class EvaluateOptimizationConsumer extends BullMqDomainAwareConsumer {
async processJob(job: Job): Promise<void> {
const data = job.data as IGroupMedicalStayJobData;
// No error handling needed!
// If domain throws error → automatic logging & metrics
await this.evaluateOptimizationUseCase.execute({
optimizationId: data.medicalStayCodingId,
shouldEvaluateReference: data.shouldEvaluateReference,
user: data.user,
});
}
}Error Mapping Strategy
| Domain Error Pattern | API Response | Consumer Action | Use Case |
|---|---|---|---|
*UnauthorizedError | 403 Forbidden | Log error | Permission checks |
*InvalidStatusError | 400 Bad Request | Log + increment metric | State validation |
*ConflictError | 409 Conflict | Log + increment metric | Race conditions |
*NotFoundError | 404 Not Found | Log error | Resource missing |
| Unknown | 500 Internal Server Error | Log error | Unexpected errors |
Benefits
- ✅ Domain stays pure - No HTTP or infrastructure concerns
- ✅ Infrastructure stays clean - No try-catch boilerplate in controllers/consumers
- ✅ Consistent handling - All errors follow same pattern (API → HTTP, Consumers → logs/metrics)
- ✅ Single source of truth - Error mapping in one place per infrastructure type
- ✅ Easy to extend - Add new mappings in handlers
- ✅ Observability - Centralized logging & metrics
5. Frontend Error Handling
The frontend receives HTTP errors from the backend and displays them to users via toast notifications. This completes the error flow from domain to user interface.
Backend API (HTTP errors) → Frontend Error Filter → User Toast NotificationError Filter (shared/helpers/error-filter.ts)
Maps HTTP status codes to user-friendly French messages:
export function mapHttpErrorToMessage(
error: unknown,
operationName?: string,
): {
title: string;
description: string;
} {
const operationContext = operationName ? ` de ${operationName}` : '';
if (error instanceof AxiosError) {
const status = error.response?.status;
switch (status) {
case 400: // Bad Request
return {
title: 'Requête invalide',
description: 'Impossible : les données fournies sont invalides.',
};
case 403: // Forbidden
return {
title: 'Accès refusé',
description: `Vous n'avez pas la permission d'effectuer cette action${operationContext}.`,
};
case 404: // Not Found
return {
title: 'Ressource introuvable',
description: `Impossible${operationContext} : la ressource demandée n'existe pas.`,
};
// ... other mappings
}
}
}Global Error Handler
Displays toast notifications automatically:
export function handleQueryError(error: unknown, operationName?: string): void {
const { title, description } = mapHttpErrorToMessage(error, operationName);
toast.error(title, { description });
}Operation-Specific Error Handlers
Create contextual error handlers for specific operations:
export function createOperationErrorHandler(operationName: string) {
return (error: unknown) => handleQueryError(error, operationName);
}Usage in React Query
For Mutations - Use createOperationErrorHandler directly in the onError callback:
export function useValidateCoding() {
return useMutation({
mutationFn: (params) => codingApi.validateCoding(params),
onError: createOperationErrorHandler('la validation du codage'),
});
}For Queries - Configure a global QueryCache error handler in App.tsx and use meta.errorMessage to opt-in:
// App.tsx - Global configuration
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
const errorMessage = query.meta?.errorMessage as string | undefined;
if (errorMessage) {
handleQueryError(error, errorMessage);
}
},
}),
});
// Query - Opt-in with meta
export function useMedicalStays() {
return useQuery({
queryKey: ['medical-stays'],
queryFn: () => codingApi.getMedicalStays(),
meta: {
errorMessage: 'la récupération des séjours',
},
});
}Error Flow Example
Complete flow from domain to user:
- Domain:
MedicalStayUnauthorizedErrorthrown inmedicalStay.validateCoding() - Backend Filter: Maps to
403 Forbiddenwith message "You don't have permission to..." - Frontend: Receives 403, maps to "Accès refusé - Vous n'avez pas la permission..."
- User: Sees toast notification in French
Frontend Error Mapping
| Backend HTTP Status | Frontend Title | Use Case |
|---|---|---|
| 400 Bad Request | Requête invalide | Invalid data |
| 401 Unauthorized | Non authentifié | Session expired |
| 403 Forbidden | Accès refusé | Permission denied |
| 404 Not Found | Ressource introuvable | Resource doesn't exist |
| 409 Conflict | Conflit | Concurrent modification |
| 422 Unprocessable | Données non traitables | Business rule violation |
| 500 Server Error | Erreur serveur | Unexpected error |
| 503 Unavailable | Service indisponible | Service down |
Frontend Rules:
- ✅ Use
createOperationErrorHandler()with descriptive operation names in French - ✅ Operation names should describe the action (e.g., "la récupération des séjours")
- ✅ All error messages are in French for end users
- ✅ Toast notifications are automatically displayed
- ❌ DON'T catch errors manually in components
- ❌ DON'T display technical error messages to users
Folder Structure
Complete Service Structure
services/
└── service-name/
├── src/
│ ├── app.module.ts
│ ├── main.ts
│ │
│ ├── domain-layer/
│ │ ├── aggregate-name/
│ │ │ ├── aggregate-name.aggregate.ts
│ │ │ ├── aggregate-name.aggregate.spec.ts
│ │ │ ├── aggregate-name.events.ts
│ │ │ ├── aggregate-name.errors.ts
│ │ │ ├── aggregate-name.value-object.ts
│ │ │ └── index.ts
│ │ └── shared/
│ │ ├── base-event.ts
│ │ └── index.ts
│ │
│ ├── application-layer/
│ │ ├── aggregate-name/
│ │ │ ├── dependencies/
│ │ │ │ ├── aggregate.repository.ts
│ │ │ │ ├── external.provider.ts
│ │ │ │ └── index.ts
│ │ │ ├── use-case-name/
│ │ │ │ ├── use-case-name.use-case.ts
│ │ │ │ ├── use-case-name.use-case.module.ts
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ └── shared/
│ │ └── dependencies/
│ │
│ └── infrastructure-layer/
│ ├── api/
│ │ ├── controllers/
│ │ │ └── aggregate-name/
│ │ │ └── operation-name/
│ │ │ ├── operation-name.controller.ts
│ │ │ ├── operation-name.controller.dto.ts
│ │ │ ├── operation-name.controller.mapper.ts
│ │ │ ├── operation-name.controller.module.ts
│ │ │ └── index.ts
│ │ ├── auth/
│ │ └── context/
│ ├── persistence/
│ │ └── prisma/
│ │ ├── repositories/
│ │ ├── mappers/
│ │ └── client/
│ ├── external-services/
│ ├── consumers/
│ ├── events/
│ ├── s3-storage/
│ └── socket/
│
├── prisma/
├── package.json
└── tsconfig.jsonThe Golden Use-Case (Command Pattern)
The Evaluate Optimization use case demonstrates all best practices in action. This is a Command (Write) operation that modifies state.
Files Involved
- Controller:
infrastructure-layer/api/controllers/optimization/optimization.controller.ts - DTOs:
infrastructure-layer/api/controllers/optimization/optimization.controller.dto.ts - Mapper:
infrastructure-layer/api/controllers/optimization/optimization.controller.mapper.ts - Use Case:
application-layer/optimization/evaluate/evaluate.use-case.ts - Use Case Module:
application-layer/optimization/evaluate/evaluate.use-case.module.ts - Repository Interface:
application-layer/optimization/dependencies/optimization.repository.ts - Provider Interface:
application-layer/optimization/dependencies/valuation.provider.ts - Aggregate:
domain-layer/optimization/optimization.aggregate.ts - Repository Implementation:
infrastructure-layer/persistence/prisma/repositories/prisma-optimization.repository.ts - Persistence Mapper:
infrastructure-layer/persistence/prisma/mappers/optimization.mapper.ts
Request Flow Walkthrough
Step 1: HTTP Request → Controller
// User makes POST request: /optimization/:id/evaluate
// Controller receives request, validates DTOs
@Post('/:id/evaluate')
async evaluateOptimization(
@Param() params: OptimizationIdParamDto, // Validated
@Body() body: EvaluateOptimizationBodyDto, // Validated
@User('tenantId') tenantId: string, // From JWT
): Promise<EvaluateOptimizationOutputDto>Step 2: Controller → Use Case
const result = await this.evaluateOptimizationUseCase.execute({
optimizationId: params.id,
shouldEvaluateReference: body.shouldEvaluateReference,
tenantId,
});Step 3: Use Case → Repository (Fetch Aggregate)
// Use case fetches domain aggregate
const optimization = await this.optimizationRepository.getOrThrow(
params.optimizationId,
);Step 4: Use Case → External Service
// Use case calls external valuation service
const { ghm, ghs, price, ... } = await this.valuationProvider.groupAndPrice({
coding: optimization.workingCoding,
})Step 5: Use Case → Domain (Execute Business Logic)
// Use case delegates to domain aggregate
optimization.evaluate({
ghm,
ghs,
price,
// ... other params
})
// Inside the aggregate:
evaluate(params): void {
this._workingCoding.group(params) // Update entity
this.computeOptimizationGain() // Business logic
this._updatedAt = new Date()
this.events.push(new OptimizationEvaluatedEvent({...})) // Emit event
}Step 6: Use Case → Repository (Persist Changes)
// Use case persists the updated aggregate
await this.optimizationRepository.update(optimization);Step 7: Use Case → Event Producer (Publish Events)
// Use case publishes domain events
await this.eventProducerProvider.sendMany({
events: optimization.events,
});Step 8: Use Case → Controller (Return Result)
return {
ghs,
ghm,
price,
optimizationGain: optimization.optimizationGain,
// ...
};Step 9: Controller → HTTP Response (Map to DTO)
// Controller maps result to DTO
return EvaluateOptimizationMapper.toDto(result);Key Takeaways
- ✅ Separation of concerns: Each layer has a clear responsibility
- ✅ Dependency inversion: Domain doesn't know about infrastructure
- ✅ Testability: Can test domain logic without infrastructure
- ✅ Encapsulation: Business logic is in the aggregate, not use case
- ✅ Event-driven: Domain events communicate what happened
The Second Golden Use-Case (Query Pattern)
The Medical Stays Table Projection demonstrates a Query (Read-Only) pattern using the Projection pattern, complementing the Command pattern shown above.
Difference: Command vs Query
| Aspect | Command (Evaluate) | Query (Projection) |
|---|---|---|
| HTTP Method | POST | GET |
| Purpose | Modify state | Read/filter data |
| Domain Logic | ✅ Yes (aggregate methods) | ⚠️ Minimal (filtering) |
| Persistence | ✅ Yes (update aggregate) | ❌ No (read-only) |
| Events | ✅ Yes (domain events) | ❌ No events |
| Optimization | Single record | Bulk data/tables |
| Complexity | Higher | Lower |
What is a Projection?
A Projection is a specialized query pattern for read-optimized views:
- Purpose: Efficiently query and filter large datasets (e.g., tables, lists)
- Optimization: Bypasses full aggregate loading for performance and avoid corruption
- Read Model: Separate from write model (CQRS principle)
- No Aggregates: Doesn't load full domain aggregates (unlike simple queries)
Files Involved
- Projection:
application-layer/projection/medical-stays-table/medical-stays-table.projection.ts - Repository Interface:
application-layer/projection/medical-stays-table/medical-stays-table.repository.ts - Repository Implementation:
infrastructure-layer/persistence/prisma/repositories/prisma-medical-stays-table-projection.repository.ts - Controller:
infrastructure-layer/api/controllers/medical-stay/get-medical-stays/get-medical-stays.controller.ts - DTOs:
infrastructure-layer/api/controllers/medical-stay/get-medical-stays/get-medical-stays.controller.dto.ts
Request Flow Walkthrough
Step 1: HTTP Request → Controller
// User makes GET request: /medical-stays?status=TO_VALIDATE&page=1
// Controller receives request, validates query params
@Get()
async getMedicalStays(
@Query() query: GetMedicalStaysQueryDto, // Validated filters
@User() user: IUser, // From JWT
): Promise<GetMedicalStaysOutputDto>Step 2: Controller → Projection
const result = await this.medicalStaysTableProjection.execute({
sourceIds: query.sourceIds,
hasMedicalInformations: query.hasMedicalInformations,
status: query.status,
sortBy: query.sortBy,
searchQuery: query.searchQuery,
referenceSeverities: query.referenceSeverities,
stayCategories: query.stayCategories,
stayTypes: query.stayTypes,
staySubtypes: query.staySubtypes,
user,
page: query.page,
});Step 3: Projection → Domain (Static Method for Authorization)
// Projection uses domain static method to determine allowed statuses
const allowedStatuses = MedicalStay.getAllowedReadStatusesForUser({
user,
status,
});Why use domain here? The domain aggregate provides a static helper method for authorization logic without loading full aggregates. This keeps business rules in the domain while maintaining query performance.
Step 4: Projection → Repository (Query Data)
// Projection delegates to repository for optimized data fetching
return this.medicalStaysTableProjectionRepository.find({
tenantId: user.tenantId,
sourceIds,
statuses: allowedStatuses, // From domain
hasMedicalCodingError: this.getHasMedicalCodingError(status),
hasMedicalInformations,
searchQuery,
referenceSeverities,
stayCategories,
stayTypes,
staySubtypes,
sortBy,
page,
});Step 5: Repository → Database (Optimized Query)
// Repository executes optimized SQL query (joins, filters, pagination)
const [rows, total] = await Promise.all([
prisma.medicalStay.findMany({
where: {
tenantId,
status: { in: statuses },
// ... complex filters
},
select: {
// Only select needed columns (not full aggregate!)
id: true,
healthcareStayId: true,
status: true,
dischargeDate: true,
medicalStayCoding: {
select: {
currentOptimizationGhm: true,
currentOptimizationPrice: true,
// ... projection-specific fields
},
},
},
orderBy: sortBy,
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
prisma.medicalStay.count({ where }),
]);Step 6: Repository → Projection (Return Results)
// Repository returns projection-specific data structure
return {
medicalStaysTableProjection: rows.map((row) => ({
id: row.id,
healthcareStayId: row.healthcareStayId,
currentOptimizationGhm: row.medicalStayCoding?.currentOptimizationGhm,
currentOptimizationPrice: row.medicalStayCoding?.currentOptimizationPrice,
status: row.status,
// ... other projected fields
})),
page,
total,
};Step 7: Projection → Controller → HTTP Response
// Controller maps projection result to DTO
return MedicalStaysTableMapper.toDto(result);Complete Projection Code
export class MedicalStaysTableProjection {
constructor(
@Inject(MEDICAL_STAYS_TABLE_PROJECTION_REPOSITORY)
private readonly medicalStaysTableProjectionRepository: MedicalStaysTableProjectionRepository,
) {}
async execute(
params: MedicalStaysTableProjectionParams,
): Promise<MedicalStaysTableProjectionResult> {
const {
sourceIds,
hasMedicalInformations,
status,
sortBy,
searchQuery,
referenceSeverities,
stayCategories,
stayTypes,
staySubtypes,
user,
page,
} = params;
// Use domain static method for authorization logic
const allowedStatuses = MedicalStay.getAllowedReadStatusesForUser({
user,
status,
});
// Delegate to repository for optimized query
return this.medicalStaysTableProjectionRepository.find({
tenantId: user.tenantId,
sourceIds,
statuses: allowedStatuses,
hasMedicalCodingError: this.getHasMedicalCodingError(status),
hasMedicalInformations,
searchQuery,
referenceSeverities,
stayCategories,
stayTypes,
staySubtypes,
sortBy,
page,
});
}
// Simple helper method (not domain logic)
private getHasMedicalCodingError(
status: CodingOrchestratorStatus | null | undefined,
): boolean | undefined {
if (status === CodingOrchestratorStatus.AI_CODING_FAILED) return true;
return undefined;
}
}Key Takeaways
- ✅ Performance optimized: Doesn't load full aggregates, only required columns
- ✅ Read-only: No state modifications, no persistence operations
- ✅ No domain logic execution: Only uses domain for static helper methods (authorization)
- ✅ Filtering & pagination: Designed for bulk data operations
- ✅ Separate read model: CQRS principle - different model for queries vs commands
- ✅ No events: Projections are side-effect free
- ⚠️ Trade-off: Less domain validation in favor of performance
Projection vs Simple Query vs Command
| Pattern | Load Aggregate? | Execute Domain Logic? | Modify State? | Use Case |
|---|---|---|---|---|
| Command | ✅ Full | ✅ Yes | ✅ Yes | Modify single entity |
| Simple Query | ✅ Full | ❌ No | ❌ No | Get single entity |
| Projection | ❌ Partial/None | ⚠️ Static only | ❌ No | List/filter many entities |
When to Use Projection Pattern
Use Projection when:
- ✅ Querying collections/lists (tables, dashboards)
- ✅ Need filtering, sorting, pagination
- ✅ Performance is critical (loading 100+ aggregates would be slow)
- ✅ Read model differs from write model
- ✅ Authorization logic is simple (can use static methods)
Best Practices for Projections
- ✅ Keep it simple: Projections are thin orchestration layers
- ✅ Use domain static methods: For authorization/filtering logic
- ✅ Optimize queries: Select only needed columns, use indexes
- ✅ Don't load full aggregates: Defeats the purpose of projections
- ✅ No side effects: Never modify state in projections
- ✅ Paginate: Always paginate large result sets
- ❌ Don't execute domain logic: No aggregate methods, only static helpers
- ❌ Don't persist: Projections are read-only
Best Practices
Domain Layer
✅ DO
// Private fields with getters
export class Optimization {
private readonly _id: string
private _optimizationGain: number | null
get id(): string {
return this._id
}
// Business logic in methods
evaluate(params: {...}): void {
this._workingCoding.group(params)
this.computeOptimizationGain()
this.events.push(new OptimizationEvaluatedEvent({...}))
}
// Private helper methods
private computeOptimizationGain(): void {
// Pure business calculation
}
}❌ DON'T
// Public mutable fields
export class Optimization {
public id: string; // ❌ Should be private with getter
public optimizationGain: number | null; // ❌ Should be private
}
// Infrastructure dependencies
import { Injectable } from '@nestjs/common'; // ❌ No NestJS in domain
import { PrismaService } from './prisma'; // ❌ No Prisma in domain
// Anemic domain model (no behavior)
export class Optimization {
id: string;
gain: number | null;
// ❌ No methods, just data (should be in aggregate)
}Application Layer
✅ DO
// Define interfaces for dependencies
export const REPOSITORY = Symbol('REPOSITORY')
export interface Repository {
getOrThrow(id: string): Promise<Aggregate>
}
// Use dependency injection
@Injectable()
export class MyUseCase implements IUseCase {
constructor(
@Inject(REPOSITORY)
private readonly repository: Repository,
) {}
async execute(params: {...}): Promise<Result> {
const aggregate = await this.repository.getOrThrow(params.id)
aggregate.doSomething(params)
await this.repository.update(aggregate)
return { ... }
}
}❌ DON'T
// Business logic in use case
async execute(params: {...}) {
const optimization = await this.repository.getOrThrow(params.id)
// ❌ Business logic should be in aggregate
const gain = optimization.workingPrice - optimization.referencePrice
optimization.optimizationGain = gain
}
// Direct database access
async execute(params: {...}) {
// ❌ Should use repository
const data = await this.prisma.medicalStay.findUnique({...})
}
// Fetching partial data
async execute(params: {...}) {
// ❌ Should fetch full aggregate
const coding = await this.repository.getCoding(params.id)
}Infrastructure Layer
✅ DO
// Implement repository interface
@Injectable()
export class PrismaRepository implements Repository {
async getOrThrow(id: string): Promise<Aggregate> {
const prismaData = await this.prisma.findUnique({...})
return Mapper.toDomain(prismaData)
}
}
// Use DTOs for validation
export class CreateDto {
@IsString()
@IsNotEmpty()
name!: string
}
// Map between layers
export const Mapper = {
toDto: (domain: Aggregate): OutputDto => ({ ... }),
toDomain: (prisma: PrismaModel): Aggregate => new Aggregate({ ... })
}❌ DON'T
// Expose Prisma models to application layer
async execute(params: {...}) {
// ❌ Should return domain aggregate
return await this.prisma.medicalStay.findUnique({...})
}
// Mix layers
export class Controller {
async handle() {
// ❌ Should call use case, not repository directly
const data = await this.repository.getOrThrow(id)
return data
}
}
// Skip validation
@Post()
async create(@Body() body: any) { // ❌ Should use DTO
// ...
}Naming Conventions
Files
- Aggregates:
aggregate-name.aggregate.ts - Entities:
entity-name.entity.ts - Value Objects:
value-object-name.value-object.ts - Events:
aggregate-name.events.ts - Use Cases:
use-case-name.use-case.ts - Repositories:
aggregate-name.repository.ts(interface) - Repository Implementations:
prisma-aggregate-name.repository.ts - DTOs:
operation-name.dto.ts - Mappers:
aggregate-name.mapper.ts
Classes & Interfaces
- Aggregates:
OptimizationAggregate→Optimization - Entities:
CodingEntity→Coding - Use Cases:
EvaluateOptimizationUseCase - Repositories:
OptimizationRepository(interface) - Repository Implementations:
PrismaOptimizationRepository - DTOs:
EvaluateOptimizationBodyDto,EvaluateOptimizationOutputDto - Mappers:
OptimizationMapper,EvaluateOptimizationMapper
Module Organization
Use Case Module
@Module({
imports: [
// Import provider modules
ParallelValuationProviderModule,
PrismaRepositoriesModule,
],
providers: [
// Register use case
EvaluateOptimizationUseCase,
],
exports: [
// Export for controllers
EvaluateOptimizationUseCase,
],
})
export class EvaluateOptimizationUseCaseModule {}Controller Module
@Module({
imports: [
// Import use case modules
EvaluateOptimizationUseCaseModule,
GetCleanedDocumentsUseCaseModule,
],
controllers: [
// Register controller
OptimizationController,
],
})
export class OptimizationControllerModule {}Common Pitfalls
1. Anemic Domain Model
// ❌ BAD: Just data, no behavior
export class Optimization {
id: string
gain: number | null
}
// Business logic in use case (wrong!)
async execute() {
const opt = await this.repo.get(id)
opt.gain = opt.workingPrice - opt.referencePrice // ❌
}
// ✅ GOOD: Rich domain model
export class Optimization {
private _optimizationGain: number | null
evaluate(params: {...}): void {
this._workingCoding.group(params)
this.computeOptimizationGain() // Business logic in domain
}
private computeOptimizationGain(): void {
this._optimizationGain = this._workingCoding.price - this._referenceCoding.price
}
}2. Infrastructure Leaking to Domain
// ❌ BAD: Domain depends on infrastructure
import { Injectable } from '@nestjs/common'; // NestJS in domain
import { ApiProperty } from '@nestjs/swagger'; // Swagger in domain
export class Optimization {
@ApiProperty() // ❌ Infrastructure annotation
id: string;
}
// ✅ GOOD: Pure domain
export class Optimization {
private readonly _id: string;
get id(): string {
return this._id;
}
}3. Direct Database Access in Use Cases
// ❌ BAD
async execute(params: {...}) {
const data = await this.prisma.medicalStay.update({...}) // ❌
}
// ✅ GOOD
async execute(params: {...}) {
const optimization = await this.repository.getOrThrow(params.id)
optimization.evaluate({...})
await this.repository.update(optimization)
}4. Business Logic in Use Cases
// ❌ BAD: Use case contains business logic
async execute(params: {...}) {
const opt = await this.repo.getOrThrow(params.id)
// ❌ This calculation should be in the aggregate
if (opt.workingPrice && opt.referencePrice) {
opt.gain = Math.round((opt.workingPrice - opt.referencePrice) * 100) / 100
}
await this.repo.update(opt)
}
// ✅ GOOD: Use case orchestrates, domain contains logic
async execute(params: {...}) {
const opt = await this.repo.getOrThrow(params.id)
opt.evaluate(params) // Business logic in aggregate
await this.repo.update(opt)
}5. Exposing Internal State
// ❌ BAD: Public mutable state
export class Optimization {
public workingCoding: Coding // ❌ Can be modified externally
}
// Client code can break invariants
optimization.workingCoding = someOtherCoding // ❌
// ✅ GOOD: Encapsulated state with methods
export class Optimization {
private _workingCoding: Coding
get workingCoding(): Coding {
return this._workingCoding // Read-only access
}
evaluate(params: {...}): void {
// Controlled mutation through method
this._workingCoding.group(params)
}
}6. Forgetting to Publish Events
// ❌ BAD: Events not published
async execute(params: {...}) {
const opt = await this.repo.getOrThrow(params.id)
opt.evaluate(params) // Creates events internally
await this.repo.update(opt)
// ❌ Forgot to publish events!
}
// ✅ GOOD: Always publish events
async execute(params: {...}) {
const opt = await this.repo.getOrThrow(params.id)
opt.evaluate(params)
await this.repo.update(opt)
await this.eventProducer.sendMany({ events: opt.events }) // ✅
}7. Not Using Dependency Injection
// ❌ BAD: Direct instantiation
export class MyUseCase {
private repository = new PrismaRepository(); // ❌ Tight coupling
}
// ✅ GOOD: Dependency injection
export class MyUseCase {
constructor(
@Inject(REPOSITORY)
private readonly repository: Repository, // ✅ Loose coupling
) {}
}8. Partial Aggregate Loading
// ❌ BAD: Loading partial data
async execute(params: {...}) {
// Only loading medical codes, not full aggregate
const codes = await this.repo.getMedicalCodes(params.id) // ❌
}
// ✅ GOOD: Loading full aggregate
async execute(params: {...}) {
const optimization = await this.repo.getOrThrow(params.id)
// Full aggregate with all entities/value objects
}Testing Strategy
Domain Layer Tests
describe('Optimization', () => {
it('should compute optimization gain correctly', () => {
// Given
const optimization = new Optimization({
referenceCoding: new Coding({ price: 1000 }),
workingCoding: new Coding({ price: 1250 }),
})
// When
optimization.evaluate({...})
// Then
expect(optimization.optimizationGain).toBe(250)
})
})Application Layer Tests
describe('EvaluateOptimizationUseCase', () => {
it('should evaluate optimization and publish events', async () => {
// Given
const mockRepo = createMock<OptimizationRepository>();
const mockProvider = createMock<ValuationProvider>();
const useCase = new EvaluateOptimizationUseCase(mockRepo, mockProvider);
// When
await useCase.execute({ optimizationId: 'id' });
// Then
expect(mockRepo.update).toHaveBeenCalled();
expect(mockEventProducer.sendMany).toHaveBeenCalled();
});
});Integration Tests
describe('Optimization E2E', () => {
it('should evaluate optimization via API', async () => {
// When
const response = await request(app.getHttpServer())
.post('/optimization/123/evaluate')
.send({ shouldEvaluateReference: false });
// Then
expect(response.status).toBe(200);
expect(response.body.GHM).toBeDefined();
});
});Error Filter Tests
describe('DomainErrorFilter', () => {
it('should map domain errors to HTTP responses', () => {
// Given
const filter = new DomainErrorFilter(mockLogger, mockMetrics);
const error = new MedicalStayUnauthorizedError(
'validate',
user,
'admin',
'id',
);
// When
filter.catch(error, mockHost);
// Then
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 403,
error: 'Forbidden',
message: expect.stringContaining('permission'),
}),
);
});
});Quick Reference Card
Layer Dependencies
Infrastructure → Application → Domain
↓ ↓ ↓
Adapters Use Cases Business LogicFile Naming
aggregate-name.aggregate.tsentity-name.entity.tsuse-case-name.use-case.tsaggregate-name.repository.ts
Key Rules
- ✅ Domain has NO external dependencies
- ✅ Application defines interfaces, Infrastructure implements
- ✅ Use cases orchestrate, Aggregates contain logic
- ✅ Always fetch full aggregates
- ✅ Publish domain events after persistence
Questions to Ask
- Is this business logic? → Put it in the aggregate
- Is this orchestration? → Put it in the use case
- Is this a technical detail? → Put it in infrastructure
- Can I test this without a database? → Should be in domain/application
Last Updated: October 2025
Maintained by: Parallel Engineering Team
Questions? Ask in #tech-crew