Infrastructure

Implement outbound ports with persistence and external services

Implements outbound ports. Three-tier structure.


Structure

infra/
├── outbound-adapters/         ← Outbound port implementations (uses `implements`)
│   └── {resource}/
│       └── {resource}.repository.ts
├── persistence/               ← Raw database access (NO `implements`)
│   └── drizzle/
│       └── {resource}/
├── external-systems/          ← Raw API adapters (NO `implements`)
│   └── {service-name}/
├── schemas/                   ← Validation schemas
│   ├── use-cases/
│   │   ├── queries/{query}/
│   │   └── commands/{command}/
│   └── http/
│       └── {resource}/{endpoint}/
└── config/                    ← Configuration and environment

Key Rule

Only outbound-adapters/ uses the implements keyword.

Persistence and external-systems are plain classes that the adapters orchestrate.


Example

Persistence (Raw Database)

persistence/drizzle/user/user.persistence.ts
class UserPersistence {
  async findById(id: string): Promise<UserRow | null> {
    return db.select().from(users).where(eq(users.id, id)).limit(1)[0];
  }

  async insert(data: UserRow): Promise<void> {
    await db.insert(users).values(data);
  }
}

Outbound Adapter (Port Implementation)

outbound-adapters/user/user.repository.ts
import { BaseOutboundAdapter } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class UserRepository
  extends BaseOutboundAdapter
  implements UserRepositoryOutboundPort
{
  constructor(private readonly persistence: UserPersistence) {
    super();
  }

  async findById(id: UserId): Promise<UserAggregate | null> {
    const row = await this.persistence.findById(id.value);
    if (!row) return null;
    return UserAggregate.reconstitute(row);  // Hydrates aggregate
  }

  async save(user: UserAggregate): Promise<void> {
    await this.persistence.insert({
      id: user.id.value,
      email: user.email,
      name: user.name,
    });
  }
}

Note: Extending BaseOutboundAdapter automatically wraps all methods with error handling, converting any thrown errors to InfraError.


Shared Infrastructure Scoping

ScopeLocationUse When
Global/packages/backend/shared/infra/Shared across all modules
Module/modules/{module}/shared/infra/Shared across BCs in one module
BC/bounded-contexts/{bc}/infra/Scoped to single BC

Simple systems can share a single database + ORM via shared/infra/.


Infrastructure Mappers

For complex domain-to-persistence mappings, use dedicated mapper objects:

persistence/drizzle/mappers/project.mapper.ts
import { Project, ProjectId, ProjectName, ProjectDescription } from '../../../domain';
import { Status, StatusMapper } from './status.mapper';
import { Task, TaskMapper } from './task.mapper';
import type { ProjectRow, NewProjectRow, StatusRow, TaskRow } from '../schema';

export const ProjectMapper = {
  /**
   * Converts database rows to domain aggregate
   */
  toDomain(row: ProjectRow, statusRows: StatusRow[], taskRows: TaskRow[]): Project {
    const statuses = statusRows.map(StatusMapper.toDomain);
    const tasks = taskRows.map(TaskMapper.toDomain);

    return Project.reconstitute(
      ProjectId.create(row.id),
      ProjectName.create(row.name),
      ProjectDescription.create(row.description ?? ''),
      statuses,
      tasks,
      row.createdAt,
      row.version,
    );
  },

  /**
   * Converts domain aggregate to database row
   */
  toRow(project: Project): NewProjectRow {
    return {
      id: project.id.value,
      name: project.name.value,
      description: project.description.value || null,
      createdAt: project.createdAt,
      version: project.version,
    };
  },
};

Using Mappers in Repositories

outbound-adapters/persistence/drizzle/project/project.repository.adapter.ts
import { ProjectMapper } from '../mappers/project.mapper';
import { StatusMapper } from '../mappers/status.mapper';

class ProjectRepository extends BaseOutboundAdapter implements ProjectRepositoryPort {
  async findById(id: ProjectId): Promise<Project | null> {
    const row = await this.db.query.projects.findFirst({
      where: eq(projects.id, id.value),
    });
    if (!row) return null;

    const statusRows = await this.db.query.statuses.findMany({
      where: eq(statuses.projectId, id.value),
    });
    const taskRows = await this.db.query.tasks.findMany({
      where: eq(tasks.projectId, id.value),
    });

    return ProjectMapper.toDomain(row, statusRows, taskRows);
  }

  async save(project: Project): Promise<void> {
    const row = ProjectMapper.toRow(project);
    await this.db.insert(projects).values(row).onConflictDoUpdate({
      target: projects.id,
      set: row,
    });

    // Save child entities
    for (const status of project.statuses) {
      const statusRow = StatusMapper.toRow(status, project.id.value);
      await this.db.insert(statuses).values(statusRow).onConflictDoUpdate({
        target: statuses.id,
        set: statusRow,
      });
    }
  }
}

Mapper Directory Structure

infra/
└── outbound-adapters/
    └── persistence/
        └── drizzle/
            ├── mappers/                ← Shared mappers
            │   ├── project.mapper.ts
            │   ├── status.mapper.ts
            │   └── task.mapper.ts
            ├── project/                ← Repository implementations
            │   └── project.repository.adapter.ts
            └── project-query/
                └── project-query.repository.adapter.ts

Info:

toDomain converts persistence (row) → domain (aggregate/entity) toRow converts domain (aggregate/entity) → persistence (row)


Aggregate Hydration

Repositories are responsible for hydrating aggregates using Aggregate.reconstitute().

Prefer partial loading over full loading when possible.


Environment Variables

  • Prohibited in Bounded Contexts
  • Allowed in Infrastructure (persistence, external, implementations)