The data model is the most consequential decision in a system, and teams make it too casually. Code gets rewritten constantly. Schemas outlast teams. A wrong column name costs you a migration. A missing foreign key costs you a redesign. A misunderstood entity relationship can invalidate months of work. Get the model right before any code exists, because that’s the cheapest point at which you’ll ever get to fix it.

Data modeling

Before writing logic, answer four questions: What information exists? Who owns it? How does it change over time? What must always remain true?

The fourth question hides a decision most teams never make consciously: distinguish events from state, upfront. Any system that touches money, handles compliance, or needs an audit trail eventually needs to answer “what was true at time T.” Mutable rows can’t answer that question — an UPDATE overwrites the old value, and it’s gone. Decide at the start whether you need an append-only event log, because retrofitting one onto a mutable schema means reconstructing history you never captured: no historical rows to migrate, only whatever you can piece together from backups, cron-job snapshots, or an application log that was never designed to be a source of truth. Some of that history is simply gone for good.

Anemic domain model. The entity is a bag of data with public fields and no constraints. Domain logic scatters across AccountService, AccountValidator, AccountHelper. Nothing enforces valid state at the type level, so any code anywhere can put the domain into a nonsensical combination.

Spot it: entity classes with only getters and setters, validation logic duplicated across services, a Utils module that touches the same entity from a dozen call sites, calls like updateAccount(accountId, { status: 4, role: "admin", frozen: false }).

Raise it: “I want our domain rules to live close to the data they protect. This validation is scattered right now — encode it in the type and the compiler enforces it everywhere. Can we look at what invariants this entity actually needs?”

Primitive obsession. Account IDs, transaction IDs, branch IDs: all number. Nothing stops you from passing an accountId where a transactionId belongs. The compiler, your most reliable reviewer, can’t see the bug.

// Anemic — any combination, valid or not
interface Account {
  id: number;     // accountId? customerId? same type
  status: number; // what does 3 mean?
  role: string;   // any string is valid?
}
updateAccount(id, { status: 3, role: "superadmin" });

// recordTransaction(transactionId, accountId, branchId) — easy to transpose, compiler won't catch it
function recordTransaction(accountId: number, transactionId: number, branchId: number) { /* ... */ }

// ---

// Domain model — constraints live with the type
type AccountId = number & { readonly __brand: "AccountId" };
type TransactionId = number & { readonly __brand: "TransactionId" };
type BranchId = number & { readonly __brand: "BranchId" };

type AccountRole = "viewer" | "editor" | "admin";

type AccountStatus =
  | { kind: "active"; activatedAt: Date }
  | { kind: "suspended"; reason: string }
  | { kind: "pendingVerification" };

interface Account {
  id: AccountId;
  role: AccountRole;
  status: AccountStatus;
}

// recordTransaction now catches transposition at compile time
function recordTransaction(accountId: AccountId, transactionId: TransactionId, branchId: BranchId) { /* ... */ }

The same discipline applies at the database layer. SQL schemas are domain models too, and they get anemic in the same way:

-- Bad: boolean flag hell in SQL
CREATE TABLE accounts (
  id          BIGSERIAL PRIMARY KEY,
  iban        TEXT NOT NULL,
  is_active   BOOLEAN NOT NULL DEFAULT false,
  is_frozen   BOOLEAN NOT NULL DEFAULT false,
  is_pending  BOOLEAN NOT NULL DEFAULT false
  -- allows is_active=true AND is_frozen=true simultaneously
);

-- Better: status with its associated data, invariants enforced by the DB
CREATE TABLE accounts (
  id              BIGSERIAL PRIMARY KEY,
  iban            TEXT NOT NULL UNIQUE,
  status          account_status NOT NULL DEFAULT 'pending_verification',
  activated_at    TIMESTAMPTZ,  -- NOT NULL when status='active'
  frozen_at       TIMESTAMPTZ,  -- NOT NULL when status='frozen'
  freeze_reason   TEXT,         -- NOT NULL when status='frozen'
  CONSTRAINT active_has_timestamp
    CHECK (status != 'active' OR activated_at IS NOT NULL),
  CONSTRAINT frozen_has_reason
    CHECK (status != 'frozen' OR (frozen_at IS NOT NULL AND freeze_reason IS NOT NULL))
);

The CHECK constraints do the same job a sealed interface does in application code. The database refuses to store a frozen account without a reason, no matter what bug exists three layers up.

And the events-vs-state question has a concrete SQL shape. A mutable transfers table throws away history. An append-only event log keeps it:

-- Append-only events: full history, queryable at any point in time
CREATE TABLE transfer_events (
  id          BIGSERIAL PRIMARY KEY,
  transfer_id BIGINT NOT NULL REFERENCES transfers(id),
  event_type  TEXT NOT NULL,  -- 'initiated', 'settled', 'reversed', 'flagged'
  payload     JSONB NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- "What was this transfer's status at 2pm yesterday?"
SELECT event_type, payload, created_at
FROM transfer_events
WHERE transfer_id = 123 AND created_at <= '2024-01-15 14:00:00'
ORDER BY created_at DESC LIMIT 1;

That question is trivial with the event log. It’s permanently unanswerable with the mutable table.

Invariants and state machines

Before writing a feature, ask what must never be false, regardless of input, timing, or state. Write it as an assertion, not a comment. Assertions fail loudly, in tests and in staging. Comments rot silently and nobody notices until the invariant they described has already broken.

For any feature with states — draft, pending review, approved, archived — model them as a discriminated union before writing code. Each variant carries a literal kind tag; TypeScript’s control-flow narrowing uses that tag to know exactly which fields exist once you’ve checked it. The union is closed by construction — it’s just a type alias listing every variant — so nobody can quietly add a fifth state without touching the type declaration itself. This forces you to enumerate every valid transition up front. The bugs that don’t exist yet live in the transitions you never modeled: the state machine says you can’t go from archived back to approved, and that rule holds everywhere, automatically.

class Draft {
  readonly kind = "draft" as const;
}

class PendingReview {
  readonly kind = "pendingReview" as const;
  constructor(readonly submittedAt: Date) {}
}

class Approved {
  readonly kind = "approved" as const;
  constructor(readonly approvedAt: Date) {}
}

class Archived {
  readonly kind = "archived" as const;
  constructor(
    readonly archivedAt: Date,
    readonly reason: string,
  ) {}
}

type LoanApplicationState = Draft | PendingReview | Approved | Archived;

// TypeScript's exhaustiveness check (the `never` in the default case) catches
// an unhandled new state at compile time — no linter required.
function statusLabel(state: LoanApplicationState): string {
  switch (state.kind) {
    case "draft":
      return "Draft";
    case "pendingReview":
      return `Pending review since ${state.submittedAt.toISOString()}`;
    case "approved":
      return `Approved on ${state.approvedAt.toISOString()}`;
    case "archived":
      return `Archived: ${state.reason}`;
    default: {
      const exhaustive: never = state;
      return exhaustive;
    }
  }
}

statusLabel reads the state; it doesn’t move between them. The transitions are where “archived can’t go back to approved” actually gets enforced, and the same discriminated-union trick that ruled out invalid states rules out invalid transitions too — by putting each transition method only on the class allowed to make it:

// Only a Draft can be submitted. PendingReview and Archived have no submit
// method — calling app.state.submit() when it's already Approved is a
// compile error, not a runtime check.
class Draft {
  readonly kind = "draft" as const;

  submit(at: Date): PendingReview {
    return new PendingReview(at);
  }
}

// Only PendingReview can be approved or sent back to Draft.
class PendingReview {
  readonly kind = "pendingReview" as const;
  constructor(readonly submittedAt: Date) {}

  approve(at: Date): Approved {
    return new Approved(at);
  }

  reject(): Draft {
    return new Draft();
  }
}

// Only an Approved application can be archived — Draft and PendingReview
// have no archive method, so "archive it before it's approved" doesn't compile.
class Approved {
  readonly kind = "approved" as const;
  constructor(readonly approvedAt: Date) {}

  archive(at: Date, reason: string): Archived {
    return new Archived(at, reason);
  }
}

A caller has to narrow down to the concrete state before it can call a transition method, and that narrowing is the checkpoint:

function approveApplication(app: LoanApplication, at: Date): void {
  if (app.state.kind !== "pendingReview") {
    throw new Error(`cannot approve application in state ${app.state.kind}`);
  }
  app.state = app.state.approve(at);
}

Try to call .approve() on an application sitting in Archived and there’s no method to call — the invalid transition isn’t caught by a check somewhere, it’s absent from the type’s API entirely. The only way to reach Approved from Archived would be to write a new method that says so explicitly, which is exactly the kind of change that should show up in a diff and get reviewed, not slip in as a missed if branch.

Boolean flag hell. Multiple booleans where only some combinations are valid. n booleans give you 2^n combinations; the domain allows a handful. IsLoading && IsSuccess is a latent bug the compiler will never catch, and you’ll eventually write a conditional to guard against it, making the codebase more defensive with every guard you add.

interface Res {
  status(code: number): { json(body: unknown): void };
}

// Before: conditional soup, impossible states representable
interface BalanceFetchResult {
  isLoading: boolean;
  isError: boolean;
  isSuccess: boolean;
  data: AccountBalance | null;
  err: Error | null;
}

function writeBalanceResponse(res: Res, r: BalanceFetchResult): void {
  if (r.isLoading) {
    res.status(202).json({ status: "pending" });
  }
  if (r.isError) {
    res.status(502).json({ error: r.err!.message });
  }
  if (r.isSuccess && r.data !== null) {
    res.status(200).json(r.data);
  }
}

// After: exhaustive switch, impossible states eliminated
type BalanceFetch =
  | { kind: "loading" }
  | { kind: "error"; err: Error }
  | { kind: "success"; data: AccountBalance };

function writeBalanceResponse(res: Res, result: BalanceFetch): void {
  switch (result.kind) {
    case "loading":
      res.status(202).json({ status: "pending" });
      break;
    case "error":
      res.status(502).json({ error: result.err.message });
      break;
    case "success":
      res.status(200).json(result.data);
      break;
  }
}

The switch version can’t represent “loading and success at once” — there’s no BalanceFetch value that means both. The booleans-on-one-object version could always represent it, silently, and did whenever writeBalanceResponse got called with a stale isLoading: true left over from before the fetch resolved.

Spot it: isLoading/isError/isSuccess as separate booleans on the same object, conditionals checking multiple flags at once, reversedAt/settledAt/archivedAt all nullable on the same object.

Raise it: “How many valid states can this object actually be in? A few of these flag combinations don’t make sense in the domain — if we model this as a discriminated union, the compiler rules out the invalid ones.”

Magic numbers and strings. A literal value with no name explaining what it represents. The next person to touch it has no context. The person who needs the same value in a different file will probably hardcode it again, slightly differently, and now you have two sources of truth drifting apart.

if (account.status === 3) redirectToVerification(); // what is 3?
setTimeout(cleanup, 86400 * 1000);                   // why that number?

// ---

const ACCOUNT_STATUS_PENDING_VERIFICATION = 3;
const HOLD_EXPIRY_MS = 24 * 60 * 60 * 1000;

Spot it: if account.Status == 3, time.AfterFunc(86400*time.Second, ...), role == "admin" repeated across files with no shared constant, a status = 2 column with no enum mapping anywhere.

Raise it: “What does this 3 mean? Let’s name it so the next person doesn’t have to go hunting. And if it’s used elsewhere, we need a shared constant to keep them in sync.”