All posts

Architecture Decisions That Prevent Technical Debt

Subhan Farrakh · · 4 min read · Updated May 19, 2026

Technical Debt is Usually an Architecture Debt

The phrase "technical debt" gets applied to everything from poorly named variables to missing tests. But the debt that actually slows teams down — the kind that makes a simple feature take three sprints — almost always traces back to an architectural decision made early on.

Not a bad decision necessarily. An uncommunicated one.

Bounded Contexts: Draw Lines Before They're Lines

The most valuable architectural work happens before you write code: deciding what belongs together and what doesn't.

In Domain-Driven Design, a bounded context is an explicit boundary around a part of the system that has its own model, its own language, and its own rules. Inside the boundary, concepts have precise meanings. Across boundaries, you translate.

Practically, this means resisting the urge to share a single User type across your entire codebase. The User in your authentication service cares about passwords and sessions. The User in your billing service cares about payment methods and invoice history. Forcing them to share a type couples two domains that should evolve independently.

ts
// ❌ One User to rule them all — becomes unmaintainable
interface User {
  id: string;
  email: string;
  passwordHash: string;    // Auth concern
  stripeCustomerId: string; // Billing concern
  preferredLocale: string;  // Profile concern
  lastLoginAt: Date;        // Session concern
}

// ✅ Context-specific models
// auth/types.ts
interface AuthUser { id: string; email: string; passwordHash: string; }

// billing/types.ts
interface BillingCustomer { userId: string; stripeCustomerId: string; }

Ports and Adapters (Hexagonal Architecture)

The architectural pattern I return to most often is Ports and Adapters, also called Hexagonal Architecture.

The core idea: your business logic (the hexagon) should know nothing about infrastructure. It expresses its needs via ports (interfaces), and the outside world fulfills those ports via adapters (implementations).

ts
// Port — defined by the domain
interface EmailService {
  send(to: string, subject: string, body: string): Promise<void>;
}

// Adapter — implementation detail, swappable
class SendGridEmailService implements EmailService {
  async send(to, subject, body) {
    await sendgrid.send({ to, subject, html: body });
  }
}

class LogEmailService implements EmailService {
  async send(to, subject, body) {
    console.log(`[email] to:${to} subject:${subject}`);
  }
}

In tests, you inject LogEmailService. In production, SendGridEmailService. Your domain code never changes.

The payoff isn't test isolation — though that's a bonus. The payoff is that when you switch from SendGrid to Postmark, the migration is a one-file change.

The Rule of Explicit Dependencies

Hidden dependencies are where maintenance cost hides. A function that reaches into a global config object, a component that reads from a global store without declaring it in props, a service that imports another service directly instead of having it injected.

Make dependencies explicit:

ts
// ❌ Hidden dependency
function getFeatureFlag(key: string) {
  return globalConfig.features[key]; // Where does globalConfig come from?
}

// ✅ Explicit dependency
function getFeatureFlag(key: string, config: FeatureConfig) {
  return config.features[key];
}

Explicit dependencies are discoverable, testable, and composable. They make the call graph visible in the code itself.

Versioned Contracts Between Services

If you have multiple services or packages communicating, the interface between them is a contract. Treat it like one.

  • Define contracts in code (TypeScript interfaces, Zod schemas, OpenAPI specs)
  • Version them explicitly when they change
  • Never change a contract in a way that breaks existing consumers without a migration path

The most underused tool here is schema validation at runtime boundaries:

ts
import { z } from 'zod';

const ArticleSchema = z.object({
  id: z.string(),
  title: z.string(),
  publishedAt: z.string().datetime(),
});

// Validate at the API boundary, not deep inside the app
const article = ArticleSchema.parse(await fetchArticle(id));

If the API contract breaks — a field is renamed, a type changes — you find out immediately at the boundary, not six function calls deep in a runtime error.

One Rule to Internalize

If you could only apply one architectural principle, make it this: separate what changes from what stays the same.

Business logic changes. Infrastructure changes. UI changes. They change at different rates and for different reasons. The architecture that ages well is the one that lets each layer change independently without cascading rewrites through the others.

Draw that boundary clearly, document why it exists, and future-you will thank present-you.