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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ prod-migrate: check-env

test-up:
@echo "🧪 Subindo Postgres TEST"
docker compose -f $(TEST_COMPOSE) up -d
docker compose -f $(TEST_COMPOSE) up -d --wait

test-down:
@echo "⬇️ Removendo containers TEST (mantendo volumes)"
Expand Down
7 changes: 6 additions & 1 deletion docker/docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ services:
postgres_test:
image: postgres:16
container_name: barber_test_db
restart: always
restart: no
environment:
POSTGRES_DB: barber_test
POSTGRES_USER: test
Expand All @@ -11,6 +11,11 @@ services:
- "5433:5432"
volumes:
- pg_test_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test -d barber_test"]
interval: 2s
timeout: 2s
retries: 10

volumes:
pg_test_data:
87 changes: 87 additions & 0 deletions docs/adr/0010-uuid-as-identifier-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# ADR 0010 – UUID as Native Identifier Type

## Context

The initial database schema defined all primary and foreign key identifiers as `TEXT`:

* `users.id`
* `barbers.id`
* `reservations.id`
* `reservations.user_id`
* `reservations.barber_id`

Although UUID values were already generated and used at the application layer, the database did not enforce UUID typing.

This created several structural problems:

* No format validation at the database level
* Possibility of persisting invalid identifier values
* Semantic mismatch between application and persistence layers
* Larger index footprint compared to native `UUID`
* Weakened type guarantees at the persistence boundary

Because the system is still in an early stage and no production data exists, the cost of correcting the schema is minimal.

Keeping identifiers as `TEXT` would be a technically weak decision: it sacrifices type safety for no practical benefit.

## Decision

1. **All identifier columns will use PostgreSQL’s native `UUID` type**

The following columns will be migrated from `TEXT` to `UUID`:

* `users.id`
* `barbers.id`
* `reservations.id`
* `reservations.user_id`
* `reservations.barber_id`

2. **UUID generation remains at the application layer**

* The database will not define a default UUID generator (`gen_random_uuid()`).
* The application remains responsible for identifier creation.
* This preserves architectural consistency and avoids hidden persistence-layer behavior.

3. **Foreign key constraints remain unchanged semantically**

* Only the column types are adjusted.
* Referential integrity rules remain intact.

## Consequences

### Positive

* Enforced structural validation at the database level
* Stronger type safety at the persistence boundary
* Improved semantic alignment between ORM and database schema
* Smaller and more efficient indexes compared to `TEXT`
* Clearer long-term schema correctness

### Negative / Trade-offs

* Requires a migration altering primary and foreign key column types
* Slightly more rigid schema (intentional constraint)
* Tighter coupling to PostgreSQL UUID support (acceptable given the PostgreSQL-first strategy)

These trade-offs are deliberate and technically justified. The benefits in correctness and integrity outweigh the migration cost.

## Resulting Structure

### Database Schema

* All primary keys defined as `UUID`
* All foreign keys referencing `UUID`
* No default UUID generation at the database level

### Application Layer

* UUID generation remains centralized in the application
* ORM models updated to reflect `UUID` type explicitly

## Final Observations

Using `TEXT` for identifiers when the domain explicitly uses UUIDs is an architectural inconsistency and a type-safety regression.

This decision restores semantic correctness at the persistence boundary and prevents subtle data integrity issues.

If, in the future, a multi-database strategy is adopted, identifier typing must be re-evaluated. For a PostgreSQL-first architecture, native `UUID` is the technically sound choice.
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ The decisions described here **reflect the actual state of the code at the time
| 0007 | Barber as Specialized User |
| 0008 | Drizzle Schema in Database Provider |
| 0009 | Postgres Error Mapper |
| 0010 | UUID as Identifier Type |



---
Expand Down
83 changes: 83 additions & 0 deletions migrations/0005_migrate_ids_to_uuid.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
BEGIN;

-- Drop constraints that depend on TEXT ids

ALTER TABLE reservations
DROP CONSTRAINT reservations_user_fk;

ALTER TABLE reservations
DROP CONSTRAINT reservations_barber_fk;

ALTER TABLE barbers
DROP CONSTRAINT barbers_user_fk;

ALTER TABLE reservations
DROP CONSTRAINT reservations_no_overlap;

-- Drop primary keys

ALTER TABLE reservations
DROP CONSTRAINT reservations_pkey;

ALTER TABLE barbers
DROP CONSTRAINT barbers_pkey;

ALTER TABLE users
DROP CONSTRAINT users_pkey;

-- Convert columns from TEXT to UUID

ALTER TABLE users
ALTER COLUMN id TYPE uuid USING id::uuid;

ALTER TABLE barbers
ALTER COLUMN id TYPE uuid USING id::uuid;

ALTER TABLE reservations
ALTER COLUMN id TYPE uuid USING id::uuid;

ALTER TABLE reservations
ALTER COLUMN user_id TYPE uuid USING user_id::uuid;

ALTER TABLE reservations
ALTER COLUMN barber_id TYPE uuid USING barber_id::uuid;

-- Recreate primary keys

ALTER TABLE users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);

ALTER TABLE barbers
ADD CONSTRAINT barbers_pkey PRIMARY KEY (id);

ALTER TABLE reservations
ADD CONSTRAINT reservations_pkey PRIMARY KEY (id);

-- Recreate foreign keys

ALTER TABLE reservations
ADD CONSTRAINT reservations_user_fk
FOREIGN KEY (user_id)
REFERENCES users(id);

ALTER TABLE reservations
ADD CONSTRAINT reservations_barber_fk
FOREIGN KEY (barber_id)
REFERENCES barbers(id);

ALTER TABLE barbers
ADD CONSTRAINT barbers_user_fk
FOREIGN KEY (id)
REFERENCES users(id)
ON DELETE CASCADE;

-- Recreate exclusion constraint (required because barber_id type changed)

ALTER TABLE reservations
ADD CONSTRAINT reservations_no_overlap
EXCLUDE USING gist (
barber_id WITH =,
period WITH &&
);

COMMIT;
14 changes: 5 additions & 9 deletions src/infrastructure/barber/barber.mapper.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { Barber } from '@domain/barber/barber.entity';

type BarberRow = {
id: string;
name: string;
bio: string | null;
active: boolean;
createdAt: Date;
};
import { barbers } from '@infrastructure/database/schema';

type BarberSelect = typeof barbers.$inferSelect;
type BarberInsert = typeof barbers.$inferInsert;
export class BarberMapper {
static toDomain(row: BarberRow): Barber {
static toDomain(row: BarberSelect): Barber {
return Barber.create({
userId: row.id,
name: row.name,
Expand All @@ -18,7 +14,7 @@ export class BarberMapper {
});
}

static toPersistence(barber: Barber): BarberRow {
static toPersistence(barber: Barber): BarberInsert {
return {
id: barber.id,
name: barber.name,
Expand Down
8 changes: 3 additions & 5 deletions src/infrastructure/database/schema/barber.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const barbers = pgTable('barbers', {
id: text('id').primaryKey(),
id: uuid('id').primaryKey(),

name: text('name').notNull(),
bio: text('bio'),
active: boolean('active').notNull().default(true),

createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
});
4 changes: 1 addition & 3 deletions src/infrastructure/database/schema/reservation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,5 @@ export const reservations = pgTable('reservations', {
startTime: timestamp('start_time', { withTimezone: true }).notNull(),
endTime: timestamp('end_time', { withTimezone: true }).notNull(),

createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
});
6 changes: 4 additions & 2 deletions src/infrastructure/database/schema/user.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
id: text('id').primaryKey(),
id: uuid('id').primaryKey(),

email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),

createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { DATABASE } from '@infrastructure/database/database.token';
import { PostgresErrorMapper } from '@infrastructure/database/postgres-error.mapper';
import { reservations } from '@infrastructure/database/schema/reservation';

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

export class ReservationDrizzleRepository implements ReservationRepository {
constructor(@Inject(DATABASE) private readonly db: DrizzleClient) {}
Expand All @@ -29,7 +29,7 @@ export class ReservationDrizzleRepository implements ReservationRepository {
}

async findById(id: string): Promise<Reservation | null> {
const [row]: ReservationRow[] = await this.db
const [row] = await this.db
.select()
.from(reservations)
.where(eq(reservations.id, id));
Expand Down
15 changes: 5 additions & 10 deletions src/infrastructure/reservation/reservation.mapper.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { Reservation } from '@domain/reservation/reservation.entity';

export type ReservationRow = {
id: string;
userId: string;
barberId: string;
startTime: Date;
endTime: Date;
createdAt: Date;
};
import { reservations } from '@infrastructure/database/schema';

type ReservationSelect = typeof reservations.$inferSelect;
type ReservationInsert = typeof reservations.$inferInsert;
export class ReservationMapper {
static toDomain(row: ReservationRow): Reservation {
static toDomain(row: ReservationSelect): Reservation {
return Reservation.create({
id: row.id,
userId: row.userId,
Expand All @@ -20,7 +15,7 @@ export class ReservationMapper {
});
}

static toPersistence(reservation: Reservation): ReservationRow {
static toPersistence(reservation: Reservation): ReservationInsert {
return {
id: reservation.id,
userId: reservation.userId,
Expand Down
15 changes: 7 additions & 8 deletions src/infrastructure/user/user.mapper.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { User } from '@domain/user/user.entity';

type userRow = {
id: string;
email: string;
passwordHash: string;
createdAt: Date;
};
import { users } from '@infrastructure/database/schema/user';

type UserSelect = typeof users.$inferSelect;
type UserInsert = typeof users.$inferInsert;

export class UserMapper {
static toDomain(row: userRow): User {
static toDomain(row: UserSelect): User {
return User.create({
id: row.id,
email: row.email,
Expand All @@ -16,7 +15,7 @@ export class UserMapper {
});
}

static toPersistence(user: User): userRow {
static toPersistence(user: User): UserInsert {
return {
id: user.id,
email: user.email.value,
Expand Down
24 changes: 15 additions & 9 deletions test/factories/barber.factory.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
import { TEST_TIME } from '@test/utils/time';
import { randomUUID } from 'crypto';

import { Barber } from '@domain/barber/barber.entity';

import { Database } from '@infrastructure/database/database.provider';
import { barbers } from '@infrastructure/database/schema/barber';

export function buildBarber(input: Partial<Barber> = {}) {
type BarberInsert = typeof barbers.$inferInsert;

export function buildBarber(input: Partial<BarberInsert> = {}) {
const id = input.id ?? randomUUID();
const name = input.name ?? 'John Doe';
const bio = input.bio ?? null;
const active = input.active ?? true;
const createdAt = input.createdAt ?? new Date();

return {
id,
name: input.name ?? 'John Barber',
bio: input.bio ?? null,
active: input.active ?? true,
createdAt: input.createdAt ?? TEST_TIME,
name,
bio,
active,
createdAt,
};
}

export async function persistBarber(db: Database, input?: Partial<Barber>) {
export async function persistBarber(
db: Database,
input?: Partial<BarberInsert>,
) {
const data = buildBarber(input);

await db.insert(barbers).values(data);
Expand Down
Loading
Loading