496303471d
---ci--- project: atelier phase: 7 milestone: v0.1 status: complete phase_role: final milestone_complete: true requirements: covered: [ATELIER-01, ATELIER-02, ATELIER-03, ATELIER-04, ATELIER-05, ATELIER-06, ATELIER-07, ATELIER-08, ATELIER-09, ATELIER-10, ATELIER-11, ATELIER-12, ATELIER-13, ATELIER-14, ATELIER-15, ATELIER-16, ATELIER-17, ATELIER-18, ATELIER-19, ATELIER-20, ATELIER-21, ATELIER-22, ATELIER-23, ATELIER-24, ATELIER-25, ATELIER-26, ATELIER-27, ATELIER-28, ATELIER-29, ATELIER-30, ATELIER-31, ATELIER-32, ATELIER-33, ATELIER-34, ATELIER-35] partial: [] ship: milestone: v0.1 type: NFR tag: v0.0.7 merge: milestone/v0.1-atelier -> main release: https://git.cloudinit.dev/cloudinit-bot/atelier/releases/tag/v0.0.7 ---/ci--- Milestone v0.1 — Initial Framework (NFR, complete). 8 core principles (C1-C8), 11 domains, 110 domain principles, 27 derived docs, 4 good + 3 bad examples, 4 language docs, full matrix, 3 review docs. All 35 requirements covered. 7 patches (v0.0.0 pre-execution through v0.0.7 final). v0.0.7 IS the v0.1.0 milestone release.
87 lines
4.5 KiB
Markdown
87 lines
4.5 KiB
Markdown
# Bad Example: Leaky Abstraction
|
|
|
|
> An abstraction that leaks its implementation details, violating Atelier principles. Each violation is cited.
|
|
|
|
## The Code
|
|
|
|
```typescript
|
|
// UserRepository — "abstracts" the database
|
|
class UserRepository {
|
|
async findAll(): Promise<UserRow[]> {
|
|
// Leaks: returns the raw DB row type, not a domain User
|
|
return db.query('SELECT id, email, password_hash, created_at, deleted_at FROM users');
|
|
}
|
|
|
|
async findByEmail(email: string): Promise<UserRow | null> {
|
|
// Leaks: the caller must know to filter deleted_at
|
|
const rows = await db.query('SELECT * FROM users WHERE email = $1', [email]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async save(user: UserRow): Promise<void> {
|
|
// Leaks: the caller must know the column names and the SQL
|
|
await db.query(
|
|
'UPDATE users SET email = $1, password_hash = $2, updated_at = now() WHERE id = $3',
|
|
[user.email, user.password_hash, user.id]
|
|
);
|
|
}
|
|
}
|
|
|
|
// Usage — the leak is visible
|
|
const repo = new UserRepository();
|
|
const user = await repo.findByEmail('jane@example.com');
|
|
if (user && !user.deleted_at) { // caller must know about soft delete
|
|
user.password_hash = await hash(newPassword); // caller must know the column
|
|
await repo.save(user); // caller must know it's an UPDATE
|
|
}
|
|
```
|
|
|
|
## Violations
|
|
|
|
### C6 Composability (Core)
|
|
- The abstraction is supposed to hide the database. It does not.
|
|
- The caller must know: the row type (`UserRow`), the soft-delete column (`deleted_at`), the password column (`password_hash`), the SQL operation (`UPDATE`).
|
|
- The abstraction is a thin wrapper. It composes nothing; it leaks everything.
|
|
- **Fix:** Return a domain `User` (no `password_hash`, no `deleted_at`). Hide soft delete (the repo filters it). Hide persistence (the caller calls `save`, not `UPDATE`).
|
|
|
|
### C2 Clarity (Core)
|
|
- The caller's code is unclear: `if (user && !user.deleted_at)` — what is `deleted_at`? Why does the caller check it?
|
|
- The abstraction was supposed to clarify. It muddied.
|
|
- **Fix:** `repo.findByEmail()` returns `User | null` (already filtered). The caller does not know soft delete exists.
|
|
|
|
### API P2 Clarity (API, by analogy)
|
|
- The repo's API exposes the DB schema in its return types. `UserRow` is a DB concept, not a domain concept.
|
|
- The public contract (return type) leaks the private implementation (the table).
|
|
- **Fix:** The return type is `User`, a domain type. `UserRow` is internal.
|
|
|
|
### Data P8 Lifecycle Awareness (Data)
|
|
- The soft-delete lifecycle (`deleted_at`) is the repo's concern. The caller should not manage it.
|
|
- By exposing `deleted_at`, the repo forces every caller to remember the filter. A forgotten filter is a soft-delete leak.
|
|
- **Fix:** The repo filters `deleted_at IS NULL` in every query. The caller never sees `deleted_at`.
|
|
|
|
### Security P9 Secret Hygiene (Security)
|
|
- `password_hash` is in the returned `UserRow`. The caller now has access to the password hash.
|
|
- A caller that logs `user` logs the hash. A caller that serializes `user` serializes the hash.
|
|
- **Fix:** `User` does not include `password_hash`. Only `UserRepository` and `AuthService` (internal) see it.
|
|
|
|
### C4 Locality (Core)
|
|
- The SQL is in the repo, but the column knowledge (`password_hash`, `deleted_at`) is in the caller.
|
|
- A column rename touches the repo AND every caller. Locality is violated.
|
|
- **Fix:** Column names are local to the repo. The caller knows only the domain `User`.
|
|
|
|
### C5 Reversibility (Core)
|
|
- Changing the database (e.g., from SQL to NoSQL, or renaming a column) requires touching every caller.
|
|
- The abstraction was supposed to make the change local. It does not.
|
|
- **Fix:** The repo's interface (`findByEmail`, `save`) is stable. The implementation changes; the callers do not.
|
|
|
|
## What This Example Reveals
|
|
|
|
The leaky abstraction is the false promise of encapsulation. The class is named `UserRepository` (suggesting it abstracts persistence), but it returns raw DB rows, exposes lifecycle columns, and leaks secret fields. The abstraction exists in name only.
|
|
|
|
The cost:
|
|
- Every caller must know the DB schema (C6 violated).
|
|
- A schema change touches every caller (C5 violated, C4 violated).
|
|
- Secret fields leak to callers (Security P9 violated).
|
|
- The lifecycle is the caller's burden (Data P8 violated).
|
|
|
|
The fix is always the same: **the abstraction's public type is the domain type, not the implementation type**. `UserRepository.findByEmail()` returns `User | null`, where `User` has `id`, `email`, `name` — and nothing else. `password_hash`, `deleted_at`, `UserRow` are internal. The caller knows nothing about the database. |