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)
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)
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
| Scope | Location | Use 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:
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
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)