Functions, Modules, and Error Handling

Learn to shape backend functions and modules, handle failures clearly, and build services that are easier to test, operate, and change.

published: reading time: 6 min read author: GeekWorkBench
Quick Summary

Functions and modules are easier to maintain when validation, persistence, and HTTP response handling have clear boundaries. An order-creation example shows how typed errors preserve useful context, while a trade-off table and production scenarios cover retries, misleading client errors, and circular imports. Use the checklist to shape service boundaries, map failures safely, and give operators enough detail to investigate.

Functions, Modules, and Error Handling

Introduction

A backend service often starts with one route handler and grows to include validation, database calls, and response formatting. Clear functions and module boundaries make that path easier to follow and change, without requiring a framework of its own.

Error handling matters at those boundaries too. Callers need to distinguish invalid requests from unavailable dependencies, while operators need enough context to diagnose failures safely.

This guide follows a small order-creation flow through module responsibilities, typed errors, production failure cases, and observability. It also covers practical checks for deciding where a function or module boundary helps.

Let modules follow responsibilities

Group functions by the reason they change. An orders module can own order rules and persistence operations; an http module handles request and response concerns. Avoid a giant utils module or many tiny modules that only re-export functions.

The route should depend on a clear use-case interface. Storage code should not choose HTTP status codes, and handlers should not duplicate order rules. These boundaries limit unrelated changes. See code quality and edge cases.

flowchart TD
    A[HTTP request] --> B[Validate input]
    B -->|invalid| C[Client error response]
    B -->|valid| D[Create order use case]
    D --> E[Order module]
    E --> F[Database]
    F -->|failure| G[Map to service error]
    F -->|saved| H[Success response]
    G --> I[Log and return safe error]

Make errors useful at the boundary

Preserve enough error context for the code that can act on it. Invalid input is usually a client problem; a database timeout is a service problem and may be retryable. Keep internal details in logs and return clients a stable, safe shape. See the request, response, and error-shape guide.

type CreateOrderInput = { customerId: string; itemIds: string[] };
type Order = { id: string; customerId: string; itemIds: string[] };

class InvalidOrderInput extends Error {}
class OrderStoreUnavailable extends Error {
  constructor(public readonly cause: unknown) {
    super("Order storage is unavailable");
  }
}

function validateOrderInput(input: CreateOrderInput): void {
  if (!input.customerId || input.itemIds.length === 0) {
    throw new InvalidOrderInput(
      "A customer and at least one item are required",
    );
  }
}

interface OrderStore {
  insert(input: CreateOrderInput): Promise<Order>;
}

async function createOrder(
  input: CreateOrderInput,
  store: OrderStore,
): Promise<Order> {
  validateOrderInput(input);
  try {
    return await store.insert(input);
  } catch (cause: unknown) {
    throw new OrderStoreUnavailable(cause);
  }
}

The route can translate InvalidOrderInput to 400 and OrderStoreUnavailable to 503. Preserve the original cause for diagnostics, but do not return it to callers. Validate before side effects.

Production failures and trade-offs

Choice Benefit Cost
Throw typed errors Keeps failure paths out of deeply nested conditionals Callers must map known errors and let unexpected ones reach a handler
Return a result union Makes success and failure visible in the type Can add repetitive branching through multiple layers
Catch near the failing operation Adds context while it is still known Catching too broadly can hide the real failure
Retry transient storage errors Can absorb short outages Retries add latency and can duplicate writes without idempotency

Typical production failures come from losing the distinction between a failed request and a failed operation:

Duplicate writes after a timeout

The database commits an order, but the connection closes before the route sends its response. The client sees a timeout and retries; without idempotency, the service creates a second order. Store an idempotency key with the result so a retry can return the original order.

A database outage reported as a client error

A broad catch maps a storage timeout to “bad request.” Clients cannot tell what happened, and monitoring misses the dependency outage. Map validation failures to a client error and known dependency failures to a service error; preserve the original cause in protected logs.

A module split prevents startup

The HTTP module imports order logic while the order module imports HTTP response types. That circular dependency can leave imports incomplete or fail during initialization. Keep dependencies pointed toward domain and use-case code, and move types used by both sides into their own module.

Observability and security checklist

  • Log the operation, request ID, error class, and dependency; never log passwords, tokens, or payment details.
  • Track failure rate, latency, and dependency timeouts. Alert on sustained changes.
  • Propagate trace context across route, use case, and storage calls.
  • Return stable error codes; keep stack traces, SQL, and hostnames out of responses.
  • Validate input and check authorization separately. Valid input does not imply permission.
  • Report dependency outages as failures instead of silently returning empty data.

Common pitfalls

  • Catch and continue: swallowing an exception can report success after a failed write. Recover only with a defined fallback.
  • Generic errors: callers cannot distinguish invalid input from an outage.
  • Leaking details: database messages and stack traces reveal internals.
  • Over-splitting: too many tiny functions make the call path hard to trace.
  • Mixing layers: changing an HTTP response should not change the order rule.

Quick recap checklist

  • Does each function have a focused, descriptive responsibility?
  • Are modules grouped around related behavior and reasons to change?
  • Are expected failures represented clearly and mapped at the service boundary?
  • Do retries have limits and protect against duplicate writes?
  • Can operators trace failures without exposing secrets to clients?

Interview Questions

1. What makes a function easy to maintain?

A focused purpose, clear name, and explicit inputs and outcomes. Split validation, persistence, and HTTP formatting when those responsibilities obscure the caller.

2. Where should an error be caught?

Catch it where code can recover or add context. Translate known failures at the API boundary; let unexpected failures reach centralized handling.

3. Why not return every internal error message to the client?

They can expose database details, hostnames, or user data. Clients need a stable code; operators need protected diagnostic logs.

4. When is retrying a failed request unsafe?

The operation may have succeeded before a connection failed. Retry only known transient failures, with a limit and duplicate protection for writes.

Further Reading

Conclusion

Clear functions and modules make a service easier to change because each part has a boundary readers can name. Clear error handling makes it easier to operate because failures retain context and reach the right audience safely. Start with a small request path, separate responsibilities only where the boundary helps, and make every failure path explicit enough that both clients and operators know what to do next.

Category

Related Posts

Backend Separation of Concerns and Module Boundaries

Learn how to set backend module boundaries, keep dependencies pointed in one direction, and avoid coupling that makes routine changes risky.

#backend #software-architecture #modularity

Backend Configuration, Environments, and Dependencies

Learn how backend services load configuration, separate development from production, validate settings, and manage dependencies without leaking secrets.

#backend #configuration #deployment

Background Jobs, Scheduling, and Worker Pools

Design background jobs and worker pools with bounded concurrency, safe retries, scheduling, and production checks that keep slow work out of request paths.

#backend #background-jobs #worker-pools