Skip to content
HomeAt Splunk, that means pairing React product work with Python services, operational data, permissions, evaluation, and release controls. Earlier at Deloitte, I built extraction, storage, API, and visualization systems; two removed more than 36,000 hours of annual manual work and cut investment-processing time by 66%.WritingArticles and notes on software engineering, AI engineering, system design, and the trade-offs behind production systems.ProjectsSelected systems, the constraints that shaped them, and the trade-offs I would revisit.Abdul AhadI’m an AI full-stack engineer with more than five years of experience building data-intensive enterprise software and production AI systems. At Splunk, I drove AI Service and KPI Discovery from an early prototype through technical design, validation, and production delivery. The capability analyzes operational data and recommends service models and health signals within Splunk IT Service Intelligence.ContactIf you’re hiring for a role, comparing architecture options, or building something with a difficult failure mode, send enough context to make the conversation useful.ColophonHow this portfolio handles static publishing, search, contact delivery, analytics, and releases.Capacity Planning Before Architecture DiagramsA practical method for turning workload assumptions into request, storage, bandwidth, concurrency, and failure-capacity budgets before choosing an architecture.Design Modules Around Change, Not LayersA practical guide to information hiding, volatile decisions, coupling, cohesion, dependency direction, and avoiding abstractions that preserve the wrong boundaries.From Model Call to Reliable AI WorkflowHow one model request grows into retrieval, tools, deterministic workflows, agents, evaluation, observability, and human escalation.Queues, Backpressure, and Idempotency: Designing for FailureHow bounded queues, admission control, idempotency keys, retry budgets, and dead-letter handling turn asynchronous delivery into a controlled reliability system.Refactoring a Legacy Backend Without a Big-Bang RewriteA staged method for understanding, testing, isolating, replacing, observing, and finally deleting legacy backend behavior without one irreversible cutover.MCP in Production: Trust Boundaries, Permissions, and Tool DesignA production-focused guide to MCP hosts, clients, servers, capability discovery, authorization, consent, token audiences, and safely designed tools.A portfolio built as a publishing systemA static-first technical publication paired with a rate-limited, AI-assisted contact workflow that is queued, retried, idempotent, and recoverable.ThreadsA discussion application with threaded posts, comments, profiles, and community spaces.Prompt NexusAn open-source application for creating, finding, and sharing prompts for AI tools.SeedA statically generated blogging application backed by a headless content system.CactaA short-video social application with publishing, discovery, profiles, and audience interactions.System DesignCapacity, queues, failure handling, and distributed-system trade-offs.AI EngineeringLLM workflows, tools, evaluation, permissions, and operations.Software EngineeringModules, testing, refactoring, interfaces, and maintainability.System Design from First PrinciplesCapacity planning, queues, backpressure, retries, and failure handling.Production AI EngineeringModel calls, workflows, tools, evaluation, permissions, and operations.

← Back to Writing
Software Engineeringarticlefoundations10 min read

Design Modules Around Change, Not Layers

A practical guide to information hiding, volatile decisions, coupling, cohesion, dependency direction, and avoiding abstractions that preserve the wrong boundaries.

ArchitectureDesign PatternsRefactoringTestingType Safety

Many applications are organized into familiar technical layers: controllers, services, repositories, and models. The structure is tidy, but tidiness is not modularity. A small product change can still require edits across every layer, and a database change can leak through the entire application.

A stronger module hides a decision that is likely to change. Its interface expresses stable business meaning while its implementation absorbs volatility.

This idea comes from David Parnas’s 1972 paper on decomposing systems into modules. Two systems can perform the same work and contain similar code while differing radically in how well they tolerate change. The difference is what each module chooses to hide.

Layers answer location; modules answer responsibility#

Consider a hypothetical subscription application. A layer-oriented tree might look like:

text
controllers/
  subscriptions.ts
services/
  subscriptions.ts
repositories/
  subscriptions.ts
models/
  subscription.ts

This tells a developer where HTTP code and database code live. It does not state which component owns trial eligibility, renewal rules, proration, or provider-specific payment behavior.

Adding a “pause subscription” rule may require edits to controller validation, service branching, repository queries, database fields, event handlers, and tests scattered by technical category.

A change-oriented structure starts with capabilities:

text
subscriptions/
  pause/
  renew/
  eligibility/
billing/
  payment-provider/
  ledger/

Technical layers can exist inside a module. They are implementation details rather than the primary architecture.

Find the volatile decisions#

A volatile decision is something the system depends on that is likely to change independently. Examples include:

  • Payment provider protocol.
  • Pricing and eligibility policy.
  • Data representation or storage engine.
  • Retry and timeout policy.
  • Identity provider.
  • Rendering framework.
  • External event schema.

The useful question is:

If this decision changes, which code should have to know?

If the answer is “most of the application,” the decision is not hidden.

For the subscription system, payment-provider behavior should live behind an interface framed in application terms:

ts
type Money = {
    amountInMinorUnits: number;
    currency: string;
};

type ChargeRequest = {
    operationId: string;
    customerId: string;
    amount: Money;
};

type ChargeResult =
    | { status: "succeeded"; receiptId: string }
    | { status: "declined"; reason: string }
    | { status: "unknown"; reconciliationId: string };

interface PaymentGateway {
    charge(request: ChargeRequest): Promise<ChargeResult>;
}

The interface does not expose a provider SDK response, transport status, or vendor-specific token type. Adapters translate those details at the boundary.

A module hides a secret#

Parnas used “information hiding” to describe modules organized around design decisions that other modules should not know. The hidden information is broader than private fields. It includes assumptions, algorithms, formats, and policies.

View diagram source
flowchart LR
    O[Subscription orchestration] --> G[PaymentGateway]
    G --> S[Stripe adapter]
    G --> B[Bank adapter]
    S --> SDK1[Provider SDK]
    B --> SDK2[Bank API]

The orchestration depends on the stable gateway contract. Provider adapters depend on unstable external protocols. Dependency direction points from policy toward a boundary, then from implementation back toward the contract.

This is not abstraction for its own sake. The gateway earns its existence because provider behavior is independently volatile and external.

Cohesion is change locality#

Cohesion is often described as keeping related functions together. A more operational definition is:

Code that changes for the same reason should be close enough to understand, test, and release together.

A module with high change cohesion might contain the rule, data required by the rule, and tests demonstrating the rule. It should not require navigation through six global layers to understand one behavior.

Use repository history as evidence. If the same files repeatedly change together, they may belong to one responsibility. If unrelated features constantly modify one “shared” service, that service may contain several responsibilities.

Coupling is knowledge, not just imports#

Two modules are coupled when one must know something about the other. Imports are visible coupling, but several forms are less obvious:

  • Sharing a database table without an ownership rule.
  • Depending on event ordering that is not part of a contract.
  • Reusing a provider DTO throughout the application.
  • Requiring coordinated deployment.
  • Depending on exact error messages.
  • Reading another module’s internal cache keys.

Reducing the number of imports does not help if both modules still depend on the same undocumented assumptions.

Make intentional coupling explicit through contracts and tests.

Compare one change across two designs#

Suppose the product introduces regional tax calculation.

Layer-oriented implementation#

  1. Controller accepts region fields.
  2. Subscription service calculates tax.
  3. Repository adds tax columns.
  4. Payment DTO includes provider tax fields.
  5. Email template reads tax fields directly.
  6. Reporting query reproduces part of the calculation.

The tax policy is distributed. A later rule change requires coordinated edits and risks inconsistent behavior.

Change-oriented implementation#

Create a TaxQuote capability:

ts
type TaxQuoteRequest = {
    jurisdiction: string;
    lineItems: Array<{ productCode: string; net: Money }>;
    effectiveAt: string;
};

type TaxQuote = {
    totalTax: Money;
    lines: Array<{ productCode: string; tax: Money }>;
    policyVersion: string;
};

The subscription flow requests a quote. Billing stores the resulting value and policy version as part of its ledger entry. Notifications render the recorded result rather than recalculating it. The volatile tax algorithm and external provider live behind one contract.

The second design has an extra interface. It also gives the tax decision one owner and makes inconsistencies testable.

Stable interfaces expose meaning#

An interface is stable when it expresses what the caller needs without leaking how the callee works.

Avoid contracts shaped like storage:

ts
findSubscriptions(where: Record<string, unknown>): Promise<SubscriptionRow[]>;

Prefer contracts shaped like intent:

ts
findRenewalsDueBefore(cutoff: Date): Promise<RenewalCandidate[]>;

The second interface is narrower. It can enforce invariants, evolve its query strategy, and return only fields required for renewal. Narrowness is a design tool: fewer possible uses mean fewer accidental dependencies.

Keep invariants with the capability#

An invariant should have one enforcement owner. If a subscription cannot be paused while payment reconciliation is pending, that rule should not be duplicated in HTTP handlers, background jobs, and UI code.

ts
function pauseSubscription(state: SubscriptionState): PauseResult {
    if (state.paymentStatus === "reconciliation_pending") {
        return { ok: false, reason: "payment_pending" };
    }
    if (state.status !== "active") {
        return { ok: false, reason: "not_active" };
    }
    return { ok: true, nextState: { ...state, status: "paused" } };
}

Transport layers translate this result into HTTP or queue responses. They do not own the rule.

Boundary tests protect useful coupling#

Modules do not need to be independent; they need explicit contracts. Test at the boundary:

  • Unit tests for policy and invariants.
  • Contract tests for adapters.
  • Integration tests for storage behavior.
  • A small number of end-to-end tests for critical flows.

For PaymentGateway, a shared contract suite can run against a fake and every real adapter:

ts
export function paymentGatewayContract(createGateway: () => PaymentGateway) {
    it("returns the same receipt for a repeated operation id", async () => {
        const gateway = createGateway();
        const first = await gateway.charge(request);
        const repeated = await gateway.charge(request);
        expect(repeated).toEqual(first);
    });
}

The test states behavior consumers rely on. It does not require every implementation to share internal structure.

Avoid the universal abstraction#

A common response to duplication is a generic layer that serves every feature: a base repository, universal event bus wrapper, or CommonService. These abstractions optimize for similarity visible today, not independent change tomorrow.

Warning signs include:

  • Configuration objects with many optional fields.
  • Callers passing mode flags to select unrelated behaviors.
  • Generic return types that callers immediately reinterpret.
  • Changes for one feature requiring regression testing for many others.
  • A shared module with no clear business owner.

Duplication is often cheaper than the wrong coupling. Wait until repeated code represents the same concept and is changing for the same reason.

When not to add a boundary#

Every interface has cost: naming, navigation, tests, and maintenance. Do not create a port for every function.

A boundary is justified when one or more are true:

  • The implementation is external or unstable.
  • The policy deserves isolated tests.
  • Multiple implementations are real, not hypothetical.
  • Security or authorization needs one enforcement point.
  • The capability has an independent lifecycle.
  • The interface reduces knowledge crossing the boundary.

Do not add an interface solely because “we might replace it someday.” Identify the concrete decision being hidden.

Evolve toward modules incrementally#

An existing layered application does not need a rewrite. For each feature change:

  1. Identify the rule or external decision involved.
  2. Write a characterization test around current behavior.
  3. Introduce a narrow contract at the point of use.
  4. Move the decision and its tests behind the contract.
  5. Translate external or storage types at the boundary.
  6. Remove the old cross-layer path after callers migrate.

Over time, the architecture begins to reflect change patterns rather than framework conventions.

Review checklist#

  • What decision does this module hide?
  • Which changes should remain local to it?
  • Does its interface express domain intent or implementation shape?
  • Are invariants enforced in one place?
  • Do provider, transport, or database types leak inward?
  • Is coupling documented and protected by boundary tests?
  • Does shared code represent one concept or superficial similarity?
  • Could a developer understand the feature without traversing global layers?

Good modules do not eliminate change. They turn change from a system-wide event into a local one.

Measure whether the boundary is paying rent#

Modularity is a hypothesis about future change, so evaluate it with change evidence. Review recent pull requests and incidents: which files changed together, which concepts repeatedly crossed boundaries, and where did a supposedly local change require coordinated edits? Co-change is imperfect—generated files and broad mechanical refactors distort it—but repeated semantic co-change can expose a misplaced boundary.

Examine the public interface too. If callers immediately deconstruct a result and rebuild domain meaning, the interface may expose representation rather than capability. If changes regularly add boolean parameters, callers may be selecting an internal policy the module should own. Conversely, a module with one implementation, no volatile decision, and extensive translation code may be an abstraction without a useful secret.

Operational ownership provides another signal. A module responsible for a capability should make failures observable in that vocabulary. “Checkout authorization failed” is more actionable than “repository method returned false.” Metrics and logs need not expose internals, but they should identify the capability and invariant at risk. This lets a team reason about the same boundary during design and production incidents.

Boundaries can be removed. If several releases show that two modules always change together because they implement one invariant, merging them may improve cohesion. If predicted provider variation never materializes, a generic adapter hierarchy can collapse into a direct implementation while retaining a narrow test seam. Deleting an abstraction is not failure; it is a correction based on evidence.

The goal is not the maximum number of independently deployable pieces. It is a codebase where important decisions have clear owners, common changes remain local, and dependencies communicate stable meaning. A boundary earns its complexity when it makes those properties easier to preserve.

Sources and further reading#

On this page

Related writing