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 structs with only getters and setters, validation logic duplicated across services, a Utils package that touches the same entity from a dozen call sites, calls like updateAccount(accountID, AccountUpdate{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 int64. 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
type Account struct {
    ID     int64  // accountID? customerID? same type
    Status int    // what does 3 mean?
    Role   string // any string is valid?
}
updateAccount(id, AccountUpdate{Status: 3, Role: "superadmin"})

// recordTransaction(transactionID, accountID, branchID) — easy to transpose, compiler won't catch it
func recordTransaction(accountID, transactionID, branchID int64) { /* ... */ }

// ---

// Domain model — constraints live with the type
type AccountID struct{ value int64 }
type TransactionID struct{ value int64 }
type BranchID struct{ value int64 }

type AccountRole string

const (
    RoleViewer AccountRole = "viewer"
    RoleEditor AccountRole = "editor"
    RoleAdmin  AccountRole = "admin"
)

type AccountStatus interface{ accountStatus() }

type Active struct{ ActivatedAt time.Time }

func (Active) accountStatus() {}

type Suspended struct{ Reason string }

func (Suspended) accountStatus() {}

type PendingVerification struct{}

func (PendingVerification) accountStatus() {}

type Account struct {
    ID     AccountID
    Role   AccountRole
    Status AccountStatus
}

// recordTransaction now catches transposition at compile time
func 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 sealed interface before writing code. 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.

type LoanApplicationState interface{ loanApplicationState() }

type Draft struct{}

func (Draft) loanApplicationState() {}

type PendingReview struct{ SubmittedAt time.Time }

func (PendingReview) loanApplicationState() {}

type Approved struct{ ApprovedAt time.Time }

func (Approved) loanApplicationState() {}

type Archived struct {
    ArchivedAt time.Time
    Reason     string
}

func (Archived) loanApplicationState() {}

// Go has no compiler-enforced exhaustiveness check — a linter like `exhaustive`
// is what catches an unhandled new state, and only if it's wired into CI.
func statusLabel(state LoanApplicationState) string {
    switch s := state.(type) {
    case Draft:
        return "Draft"
    case PendingReview:
        return fmt.Sprintf("Pending review since %s", s.SubmittedAt.Format(time.RFC3339))
    case Approved:
        return fmt.Sprintf("Approved on %s", s.ApprovedAt.Format(time.RFC3339))
    case Archived:
        return fmt.Sprintf("Archived: %s", s.Reason)
    }
    return "unknown"
}

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.

// Before: conditional soup, impossible states representable
type BalanceFetchResult struct {
    IsLoading bool
    IsError   bool
    IsSuccess bool
    Data      *AccountBalance
    Err       error
}

func writeBalanceResponse(w http.ResponseWriter, r BalanceFetchResult) {
    if r.IsLoading {
        writeJSON(w, 202, map[string]string{"status": "pending"})
    }
    if r.IsError {
        writeJSON(w, 502, map[string]string{"error": r.Err.Error()})
    }
    if r.IsSuccess && r.Data != nil {
        writeJSON(w, 200, r.Data)
    }
}

// After: exhaustive-in-practice switch, impossible states eliminated
type BalanceFetch interface{ balanceFetch() }

type Loading struct{}

func (Loading) balanceFetch() {}

type FetchError struct{ Err error }

func (FetchError) balanceFetch() {}

type FetchSuccess struct{ Data AccountBalance }

func (FetchSuccess) balanceFetch() {}

func writeBalanceResponse(w http.ResponseWriter, result BalanceFetch) {
    switch r := result.(type) {
    case Loading:
        writeJSON(w, 202, map[string]string{"status": "pending"})
    case FetchError:
        writeJSON(w, 502, map[string]string{"error": r.Err.Error()})
    case FetchSuccess:
        writeJSON(w, 200, r.Data)
    }
}

The switch version can’t represent “loading and success at once” — there’s no BalanceFetch value that means both. The struct-of-booleans 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 struct, conditionals checking multiple flags at once, ReversedAt/SettledAt/ArchivedAt all nullable on the same object.

Raise it: “How many valid states can this struct actually be in? A few of these flag combinations don’t make sense in the domain — if we model this as a sealed interface, 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?
time.AfterFunc(86400*time.Second, cleanup)            // why that number?

// ---

const AccountStatusPendingVerification = 3
const HoldExpiry = 24 * time.Hour

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