Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ TREE_IGNORE = node_modules|.git|dist|*.env*
.PHONY: \
dev-up dev-down dev-reset dev-migrate \
prod-up prod-migrate \
test-up test-down test-reset test-migrate test-run \
test test-up test-down test-reset test-migrate test-run \
lint typecheck build \
check-dev-env check-prod-env check-test-env \
tree
Expand Down Expand Up @@ -55,9 +55,12 @@ typecheck:
pnpm tsc --noEmit

ci-test:
make test-reset
make test-up
make test-migrate
make test
make test-down

# ─────────────────────────────────────────────────────────────
# DEV
# ─────────────────────────────────────────────────────────────
Expand Down
70 changes: 70 additions & 0 deletions docs/adr/0008-drizzle-schema-in-database-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# ADR 0008 – Injecting Drizzle Schemas into Database Provider for Type Safety

## Context

The project uses Drizzle ORM for database access with SQL-first migrations.
Drizzle allows injecting schema definitions directly into the database instance, enabling full TypeScript type inference and IDE autocomplete.

A strict Clean Architecture approach would separate:

* Database connection provider (infrastructure bootstrap)
* ORM schema mapping layer
* Repository implementations

In such architecture, the database provider would expose only a raw connection, and repositories would import schemas explicitly.
However, this approach increases boilerplate and reduces developer experience (DX), especially in small single-service codebases.

## Decision

1. **Drizzle schemas will be injected directly into the database provider**

* The database provider will return a typed Drizzle instance with schema metadata attached.
* Repositories will consume the typed database instance without manually importing schema definitions.

2. **Schema definitions will remain in the infrastructure layer**

* Domain entities and value objects will not depend on Drizzle or database schema types.
* Mapping between domain and persistence will continue to be handled via mappers.

3. **This coupling is accepted as a conscious trade-off for improved DX**

* The project is a single-service portfolio and study system, where productivity and type safety outweigh future modularization concerns.

## Consequences

### Positive

* Full TypeScript type inference for queries and relations
* Improved IDE autocomplete and developer productivity
* Reduced boilerplate in repository implementations
* Lower risk of runtime type mismatches

### Negative / Trade-offs

* Database provider becomes coupled to all schema definitions
* Harder to modularize or split into multiple services in the future
* Less flexibility for dynamic schema loading or multi-database architectures

These trade-offs are considered acceptable for a portfolio and learning-oriented project.

## Resulting Structure

### infrastructure/database/database.provider.ts

* Responsible for creating the PostgreSQL connection pool
* Initializes Drizzle with schema injection for typed database access

### infrastructure/database/schema/*

* Contains Drizzle schema definitions for all tables
* Does not import domain entities or value objects

### infrastructure/*/repositories

* Consume the typed database instance
* Map persistence models to domain entities using explicit mappers

## Final Observations

This decision prioritizes **developer experience, type safety, and clarity of repository code** over strict architectural purity.
If the system evolves into a multi-module or distributed architecture, the schema injection strategy should be revisited and moved into a dedicated ORM adapter layer.
67 changes: 67 additions & 0 deletions docs/adr/0009-postgres-error-mapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# ADR 0009 – Centralized PostgreSQL Error Mapping Adapter

## Context

Repositories currently inspect database errors directly to detect PostgreSQL constraint violations (e.g., unique constraint `23505`).
This logic was implemented inline in repository classes, resulting in:

* Duplication of error parsing logic across repositories
* Tight coupling between repositories and PostgreSQL error codes
* Reduced readability and maintainability
* Violation of Clean Architecture boundaries (infrastructure concerns leaking into repositories)

Drizzle ORM wraps PostgreSQL errors, requiring custom logic to extract the underlying error code, which further complicates repository implementations.

## Decision

1. **Introduce a dedicated PostgresErrorMapper in the infrastructure layer**

* Centralizes PostgreSQL error code extraction and classification.
* Abstracts Drizzle and pg error structures from repositories.

2. **Repositories will depend only on semantic error checks**

* Example: `PostgresErrorMapper.isUniqueViolation(error)`
* Repositories will no longer parse error objects manually.

3. **The mapper remains an infrastructure concern**

* Domain and application layers remain unaware of PostgreSQL-specific details.
* Only infrastructure repositories may reference the mapper.

## Consequences

### Positive

* Eliminates duplicated error parsing logic
* Improves repository readability and maintainability
* Reduces PostgreSQL coupling leakage into repository code
* Centralizes future database-specific error handling changes
* Easier to extend for additional PostgreSQL error codes

### Negative / Trade-offs

* Introduces an additional infrastructure abstraction layer
* Still PostgreSQL-specific (not a generic SQL error adapter)
* If multi-database support is required, a higher-level error abstraction layer will be needed

These trade-offs are acceptable given the PostgreSQL-first architecture strategy of this project.

## Resulting Structure

### infrastructure/database/postgres-error.mapper.ts

* Responsible for:
* Extracting PostgreSQL error codes from pg and Drizzle error objects
* Providing semantic helpers (e.g., unique constraint violation detection)

### infrastructure/*/*.drizzle-repository.ts

* Uses PostgresErrorMapper for database error classification
* Maps database errors to domain-specific errors (e.g., ReservationConflictError)

## Final Observations

This decision reinforces Clean Architecture boundaries by isolating database-specific error handling logic.
Repositories now focus strictly on persistence behavior and domain mapping, while infrastructure-specific error parsing remains centralized and reusable.
If the system evolves into a multi-database architecture, this mapper should be replaced with a generic database error abstraction layer.
5 changes: 4 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ The decisions described here **reflect the actual state of the code at the time
| 0002 | Architecture Enforcement via Tooling |
| 0003 | PostgreSQL-First Database Strategy |
| 0004 | SQL-First Migrations |
| 0005 | Drizzle over Prisma |
| 0005 | Drizzle Over Prisma |
| 0006 | SQL-First Migration Runner |
| 0007 | Barber as Specialized User |
| 0008 | Drizzle Schema in Database Provider |
| 0009 | Postgres Error Mapper |


---

Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default tseslint.config(
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
sourceType: 'module',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
Expand Down
3 changes: 2 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
Expand All @@ -9,6 +8,8 @@ const config: Config = {
'^@domain/(.*)$': '<rootDir>/src/domain/$1',
'^@application/(.*)$': '<rootDir>/src/application/$1',
'^@infrastructure/(.*)$': '<rootDir>/src/infrastructure/$1',
'^@http/(.*)$': '<rootDir>/src/http/$1',
'^@test/(.*)$': '<rootDir>/test/$1',
},
};

Expand Down
1 change: 1 addition & 0 deletions src/domain/reservation/reservation.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import { Reservation } from './reservation.entity';

export interface ReservationRepository {
save(reservation: Reservation): Promise<void>;
findById(id: string): Promise<Reservation | null>;
}
9 changes: 4 additions & 5 deletions src/infrastructure/database/database.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';

import { DatabaseUrlNotDefinedError } from './errors/database-url-not-defined.error';
import * as schema from './schema';

export function createDatabase() {
const url = process.env.DATABASE_URL;

if (!url) {
throw new DatabaseUrlNotDefinedError();
}
if (!url) throw new DatabaseUrlNotDefinedError();

const pool = new Pool({ connectionString: url });
return drizzle(pool);

return drizzle(pool, { schema });
}

export type Database = ReturnType<typeof createDatabase>;
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InfrastructureError } from '@infrastructure/errors/infrastructure';
import { InfrastructureError } from '../../errors/infrastructure';

export class DatabaseUrlNotDefinedError extends InfrastructureError {
constructor() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InfrastructureError } from '@infrastructure/errors/infrastructure';
import { InfrastructureError } from '../../errors/infrastructure';

export class UniqueConstraintViolationError extends InfrastructureError {
constructor(readonly constraint: string) {
Expand Down
47 changes: 47 additions & 0 deletions src/infrastructure/database/postgres-error.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export class PostgresErrorMapper {
static isPostgresError(error: unknown): error is { code: string } {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof error.code === 'string'
);
}

static extractCode(error: unknown): string | null {
if (!error || typeof error !== 'object') return null;

// Direct pg error
if (this.isPostgresError(error)) {
return error.code;
}

// Drizzle wraps pg errors inside `cause`
if ('cause' in error && error.cause && typeof error.cause === 'object') {
if (
'code' in error.cause &&
typeof (error.cause as { code: unknown }).code === 'string'
) {
return (error.cause as { code: string }).code;
}
}

return null;
}

static isUniqueViolation(error: unknown): boolean {
return this.extractCode(error) === '23P01';
}

static isForeignKeyViolation(error: unknown): boolean {
return this.extractCode(error) === '23503';
}

static isNotNullViolation(error: unknown): boolean {
return this.extractCode(error) === '23502';
}

static isCheckViolation(error: unknown): boolean {
return this.extractCode(error) === '23514';
}
}
11 changes: 0 additions & 11 deletions src/infrastructure/database/schema/drizzle.ts

This file was deleted.

3 changes: 3 additions & 0 deletions src/infrastructure/database/schema/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './barber';
export * from './reservation';
export * from './user';
28 changes: 13 additions & 15 deletions src/infrastructure/reservation/reservation.drizzle-repository.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,39 @@
import { Inject } from '@nestjs/common';
import { eq } from 'drizzle-orm';

import { ReservationConflictError } from '@domain/reservation/errors/reservation-conflict.error';
import { Reservation } from '@domain/reservation/reservation.entity';
import { ReservationRepository } from '@domain/reservation/reservation.repository';

import { Database } from '@infrastructure/database/database.provider';
import { DATABASE } from '@infrastructure/database/database.token';
import { DrizzleDatabase } from '@infrastructure/database/schema/drizzle';
import { PostgresErrorMapper } from '@infrastructure/database/postgres-error.mapper';
import { reservations } from '@infrastructure/database/schema/reservation';

import { ReservationMapper } from './reservation.mapper';
import { ReservationMapper, ReservationRow } from './reservation.mapper';

export class ReservationDrizzleRepository implements ReservationRepository {
constructor(@Inject(DATABASE) private readonly db: DrizzleDatabase) {}
constructor(@Inject(DATABASE) private readonly db: Database) {}

async save(reservation: Reservation): Promise<void> {
try {
const data = ReservationMapper.toPersistence(reservation);

await this.db.insert(reservations).values(data);
} catch (error: unknown) {
if (this.errorHasCode(error, '23P01')) {
if (PostgresErrorMapper.isUniqueViolation(error)) {
throw new ReservationConflictError();
}

throw error;
}
}

private errorHasCode(error: unknown, code: string): boolean {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === code
) {
return true;
}
return false;
async findById(id: string): Promise<Reservation | null> {
const [row]: ReservationRow[] = await this.db
.select()
.from(reservations)
.where(eq(reservations.id, id));

return row ? ReservationMapper.toDomain(row) : null;
}
}
2 changes: 1 addition & 1 deletion src/infrastructure/reservation/reservation.mapper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Reservation } from '@domain/reservation/reservation.entity';

type ReservationRow = {
export type ReservationRow = {
id: string;
userId: string;
barberId: string;
Expand Down
4 changes: 2 additions & 2 deletions src/infrastructure/user/user.drizzle-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import { eq } from 'drizzle-orm';
import { User } from '@domain/user/user.entity';
import { UserRepository } from '@domain/user/user.repository';

import { Database } from '@infrastructure/database/database.provider';
import { DATABASE } from '@infrastructure/database/database.token';
import { UniqueConstraintViolationError } from '@infrastructure/database/errors/unique-constraint-violation.error';
import { DrizzleDatabase } from '@infrastructure/database/schema/drizzle';
import { users } from '@infrastructure/database/schema/user';

import { UserMapper } from './user.mapper';

@Injectable()
export class UserDrizzleRepository implements UserRepository {
constructor(@Inject(DATABASE) private readonly db: DrizzleDatabase) {}
constructor(@Inject(DATABASE) private readonly db: Database) {}
async findByEmail(email: string): Promise<User | null> {
const result = await this.db
.select()
Expand Down
Loading
Loading