Skip to content

Domain-Driven Design at Parallel

Table of Contents

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/

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

  1. Request enters via Infrastructure (Controller)
  2. Use case orchestrates the business operation
  3. Domain logic executes (aggregates, entities)
  4. Infrastructure persists changes via repositories
  5. 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:

typescript
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.ts

Dependency Interface Pattern:

typescript
// 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:

typescript
@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/                     # Websocket

Controller Pattern:

typescript
@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:

typescript
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:

typescript
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.ts

Repository Implementation Pattern:

typescript
@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:

typescript
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:

typescript
@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

typescript
// ✅ 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

typescript
// 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

typescript
// 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 metrics

1. Domain Errors (domain-layer/)

Define business rule violations as domain errors:

typescript
// 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 DomainError base 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:

typescript
// 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:

typescript
// 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:

typescript
@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: DomainErrorFilter,
    },
  ],
})
export class AppModule {}

4. Controllers Stay Clean

No try-catch needed - filter handles everything:

typescript
@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:

typescript
// 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:

typescript
@Global()
@Module({
  providers: [
    {
      provide: DOMAIN_CONSUMER_ERROR_HANDLER,
      useClass: DomainConsumerErrorHandler,
    },
  ],
})
export class DomainConsumerErrorHandlerModule {}

Consumers stay clean - no try-catch needed:

typescript
@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 PatternAPI ResponseConsumer ActionUse Case
*UnauthorizedError403 ForbiddenLog errorPermission checks
*InvalidStatusError400 Bad RequestLog + increment metricState validation
*ConflictError409 ConflictLog + increment metricRace conditions
*NotFoundError404 Not FoundLog errorResource missing
Unknown500 Internal Server ErrorLog errorUnexpected 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 Notification

Error Filter (shared/helpers/error-filter.ts)

Maps HTTP status codes to user-friendly French messages:

typescript
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:

typescript
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:

typescript
export function createOperationErrorHandler(operationName: string) {
  return (error: unknown) => handleQueryError(error, operationName);
}

Usage in React Query

For Mutations - Use createOperationErrorHandler directly in the onError callback:

typescript
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:

typescript
// 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:

  1. Domain: MedicalStayUnauthorizedError thrown in medicalStay.validateCoding()
  2. Backend Filter: Maps to 403 Forbidden with message "You don't have permission to..."
  3. Frontend: Receives 403, maps to "Accès refusé - Vous n'avez pas la permission..."
  4. User: Sees toast notification in French

Frontend Error Mapping

Backend HTTP StatusFrontend TitleUse Case
400 Bad RequestRequête invalideInvalid data
401 UnauthorizedNon authentifiéSession expired
403 ForbiddenAccès refuséPermission denied
404 Not FoundRessource introuvableResource doesn't exist
409 ConflictConflitConcurrent modification
422 UnprocessableDonnées non traitablesBusiness rule violation
500 Server ErrorErreur serveurUnexpected error
503 UnavailableService indisponibleService 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.json

The 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

  1. Controller: infrastructure-layer/api/controllers/optimization/optimization.controller.ts
  2. DTOs: infrastructure-layer/api/controllers/optimization/optimization.controller.dto.ts
  3. Mapper: infrastructure-layer/api/controllers/optimization/optimization.controller.mapper.ts
  4. Use Case: application-layer/optimization/evaluate/evaluate.use-case.ts
  5. Use Case Module: application-layer/optimization/evaluate/evaluate.use-case.module.ts
  6. Repository Interface: application-layer/optimization/dependencies/optimization.repository.ts
  7. Provider Interface: application-layer/optimization/dependencies/valuation.provider.ts
  8. Aggregate: domain-layer/optimization/optimization.aggregate.ts
  9. Repository Implementation: infrastructure-layer/persistence/prisma/repositories/prisma-optimization.repository.ts
  10. Persistence Mapper: infrastructure-layer/persistence/prisma/mappers/optimization.mapper.ts

Request Flow Walkthrough

Step 1: HTTP Request → Controller

typescript
// 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

typescript
const result = await this.evaluateOptimizationUseCase.execute({
  optimizationId: params.id,
  shouldEvaluateReference: body.shouldEvaluateReference,
  tenantId,
});

Step 3: Use Case → Repository (Fetch Aggregate)

typescript
// Use case fetches domain aggregate
const optimization = await this.optimizationRepository.getOrThrow(
  params.optimizationId,
);

Step 4: Use Case → External Service

typescript
// 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)

typescript
// 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)

typescript
// Use case persists the updated aggregate
await this.optimizationRepository.update(optimization);

Step 7: Use Case → Event Producer (Publish Events)

typescript
// Use case publishes domain events
await this.eventProducerProvider.sendMany({
  events: optimization.events,
});

Step 8: Use Case → Controller (Return Result)

typescript
return {
  ghs,
  ghm,
  price,
  optimizationGain: optimization.optimizationGain,
  // ...
};

Step 9: Controller → HTTP Response (Map to DTO)

typescript
// 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

AspectCommand (Evaluate)Query (Projection)
HTTP MethodPOSTGET
PurposeModify stateRead/filter data
Domain Logic✅ Yes (aggregate methods)⚠️ Minimal (filtering)
Persistence✅ Yes (update aggregate)❌ No (read-only)
Events✅ Yes (domain events)❌ No events
OptimizationSingle recordBulk data/tables
ComplexityHigherLower

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

  1. Projection: application-layer/projection/medical-stays-table/medical-stays-table.projection.ts
  2. Repository Interface: application-layer/projection/medical-stays-table/medical-stays-table.repository.ts
  3. Repository Implementation: infrastructure-layer/persistence/prisma/repositories/prisma-medical-stays-table-projection.repository.ts
  4. Controller: infrastructure-layer/api/controllers/medical-stay/get-medical-stays/get-medical-stays.controller.ts
  5. DTOs: infrastructure-layer/api/controllers/medical-stay/get-medical-stays/get-medical-stays.controller.dto.ts

Request Flow Walkthrough

Step 1: HTTP Request → Controller

typescript
// 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

typescript
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)

typescript
// 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)

typescript
// 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)

typescript
// 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)

typescript
// 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

typescript
// Controller maps projection result to DTO
return MedicalStaysTableMapper.toDto(result);

Complete Projection Code

typescript
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

PatternLoad Aggregate?Execute Domain Logic?Modify State?Use Case
Command✅ Full✅ Yes✅ YesModify single entity
Simple Query✅ Full❌ No❌ NoGet single entity
Projection❌ Partial/None⚠️ Static only❌ NoList/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

  1. Keep it simple: Projections are thin orchestration layers
  2. Use domain static methods: For authorization/filtering logic
  3. Optimize queries: Select only needed columns, use indexes
  4. Don't load full aggregates: Defeats the purpose of projections
  5. No side effects: Never modify state in projections
  6. Paginate: Always paginate large result sets
  7. Don't execute domain logic: No aggregate methods, only static helpers
  8. Don't persist: Projections are read-only

Best Practices

Domain Layer

✅ DO

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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: OptimizationAggregateOptimization
  • Entities: CodingEntityCoding
  • Use Cases: EvaluateOptimizationUseCase
  • Repositories: OptimizationRepository (interface)
  • Repository Implementations: PrismaOptimizationRepository
  • DTOs: EvaluateOptimizationBodyDto, EvaluateOptimizationOutputDto
  • Mappers: OptimizationMapper, EvaluateOptimizationMapper

Module Organization

Use Case Module

typescript
@Module({
  imports: [
    // Import provider modules
    ParallelValuationProviderModule,
    PrismaRepositoriesModule,
  ],
  providers: [
    // Register use case
    EvaluateOptimizationUseCase,
  ],
  exports: [
    // Export for controllers
    EvaluateOptimizationUseCase,
  ],
})
export class EvaluateOptimizationUseCaseModule {}

Controller Module

typescript
@Module({
  imports: [
    // Import use case modules
    EvaluateOptimizationUseCaseModule,
    GetCleanedDocumentsUseCaseModule,
  ],
  controllers: [
    // Register controller
    OptimizationController,
  ],
})
export class OptimizationControllerModule {}

Common Pitfalls

1. Anemic Domain Model

typescript
// ❌ 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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
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

typescript
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

typescript
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

typescript
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 Logic

File Naming

  • aggregate-name.aggregate.ts
  • entity-name.entity.ts
  • use-case-name.use-case.ts
  • aggregate-name.repository.ts

Key Rules

  1. ✅ Domain has NO external dependencies
  2. ✅ Application defines interfaces, Infrastructure implements
  3. ✅ Use cases orchestrate, Aggregates contain logic
  4. ✅ Always fetch full aggregates
  5. ✅ 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