Insights

Engineering Notes

Technical notes, updates, and essays from the team.

Design Principles for Durable Software

30 June 2024 — Zigla City

Why Principles Matter More Than Patterns

Design patterns are tools. Principles are the reasoning behind when to pick up a tool and when to leave it on the shelf. After building systems across healthcare, fintech, and logistics, we've distilled our daily architecture decisions into a small set of principles that survive contact with reality.

"A complex system that works is invariably found to have evolved from a simple system that worked." — Gall's Law


Principle 1: Explicit Over Implicit

Every piece of behaviour in a codebase should be visible and intentional — not inherited from a framework convention that a new team member might not know about.

Bad: Implicit side effects

// What does this do? You have to trace into the class to find out.
class UserService {
  constructor(private repo: UserRepository) {
    this.repo.onSave((u) => sendWelcomeEmail(u)); // 👻 hidden side effect
  }
}

Better: Explicit post-save handler

export async function createUser(data: CreateUserInput, deps: UserDeps) {
  const user = await deps.repo.save(data);
  await deps.emailService.sendWelcome(user); // clearly visible
  return user;
}

The second form is testable, legible, and obvious to someone reading it for the first time.


Principle 2: Boundaries Before Abstractions

Teams rush to create shared abstractions too early. A BaseService that three features inherit from sounds like DRY — until Feature A needs X and Feature B needs Y, and the base class becomes a graveyard of if (this.mode === ...) branches.

Rule of three: only extract a shared abstraction once you have three or more independent call sites with genuinely identical logic.

Situation Recommendation
2 call sites, slightly different logic Duplicate. Wait.
3+ call sites, truly identical Extract to shared utility
Shared state with different workflows Use composition, not inheritance
Cross-cutting concern (logging, auth) Use middleware / decorator pattern

Principle 3: Push Side Effects to the Edges

Functional core, imperative shell. Keep your domain logic pure — no DB calls, no HTTP, no random numbers. Push all impure effects to the outermost layer.

// ✅ Pure domain logic — easy to unit test
function calculateDiscount(order: Order, policy: DiscountPolicy): number {
  if (order.total >= policy.threshold) return policy.rate;
  return 0;
}

// ✅ Imperative shell — handles all I/O
async function applyDiscount(orderId: string) {
  const [order, policy] = await Promise.all([
    db.order.findUnique({ where: { id: orderId } }),
    db.discountPolicy.findFirst(),
  ]);
  const discount = calculateDiscount(order!, policy!); // pure call
  await db.order.update({ where: { id: orderId }, data: { discount } });
}

calculateDiscount has zero dependencies on the database or external services. You can test it with a simple assert.


Principle 4: Make Failure Visible

Systems fail. The question is whether those failures are silent (data corruption, ghost transactions) or loud (an exception with a full stack trace and a clear error message).

Always prefer loud failures:

  • Validate inputs at system boundaries, not deep inside domain logic
  • Throw specific, descriptive error types (PaymentDeclinedError, UserNotFoundError)
  • Never swallow exceptions with an empty catch block
  • Return Result<T, E> types rather than nullable values where ambiguity is high
// ❌ Silent failure — null propagates invisibly
async function getUser(id: string): Promise<User | null> {
  return db.user.findUnique({ where: { id } });
}

// ✅ Loud failure — callers are forced to handle the case
async function getUserOrThrow(id: string): Promise<User> {
  const user = await db.user.findUnique({ where: { id } });
  if (!user) throw new UserNotFoundError(id);
  return user;
}

Principle 5: Defer Decisions

The best architecture decision is often the one you don't make today. Premature decisions lock you in. Deferral keeps options open.

Practical examples:

  1. Start with a monolith — split services when you have clear, stable service boundaries backed by operational data
  2. Use a managed DB — tune it before reaching for Redis, Elasticsearch, or specialised stores
  3. Deploy a simple queue — only migrate to Kafka-level infrastructure once message volume or ordering guarantees demand it
  4. Keep auth simple — username+password+JWT before OAuth, SSO, or passkeys

Deferral is not procrastination. It is evidence-based decision timing.


Principle 6: Optimise for the Reader

Codebases are read 10× more than they are written. Every variable name, function signature, and module boundary is a communication act.

Naming heuristics:

  • Functions that return booleans start with is, has, can, should
  • Functions that create something start with create, build, make
  • Functions that retrieve state start with get, find, fetch, load
  • Avoid generic names like data, info, manager, handler
// ❌ Unclear
const d = await fetch_data(u);
if (check(d)) process(d);

// ✅ Self-documenting
const subscription = await fetchSubscription(userId);
if (isSubscriptionActive(subscription)) processRenewal(subscription);

Applying These Principles

These six principles work together. Explicit code is easier to push to the edges. Deferred decisions survive longer when boundaries are clean. Visible failures are only possible when behaviour is explicit.

We review code against these principles in PRs. Not as a checklist, but as a conversation: "Does this change make the system easier to understand six months from now?"

If the answer is yes, ship it. If not, let's talk.

Explore how we apply these to client projects