Files
atelier/examples/bad/leaky-abstraction.md
T
Jon Chery 496303471d docs(milestone): complete v0.1 — initial framework
---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.
2026-08-05 00:36:55 +00:00

4.5 KiB

Bad Example: Leaky Abstraction

An abstraction that leaks its implementation details, violating Atelier principles. Each violation is cited.

The Code

// 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.