Every Go engineer has written this:

1
2
3
if err != nil {
    return err
}

It feels like error handling. In a layered architecture, it’s the beginning of the problem. Errors travel from persistence through business logic to the API handler, and at every stop a layer can amplify them, misinterpret them, or leak their internals to places they were never meant to reach.

This talk is drawn from real work at Iknite Inc, where I was tech lead on a backend service that hit three of these failure modes in production. The sample app — a small e-commerce API, no framework, no ORM, every error decision explicit — was built specifically to reproduce them cleanly.

Anti-pattern 1: Excessive Wrapping

Every layer stamps its signature on the way out. A missing order ends up returning something like:

1
2
3
handler: GET /orders/{id} failed: service layer: failed to retrieve
order details: order lookup failed: repository: GetOrder query failed:
order record not found in database: sql: no rows in result set

Four layer prefixes before the actual cause. An on-call engineer reading this at 3am is parsing a paragraph to find five words of signal. The prefixes don’t add information — you already have logs, you already have a call stack. The sweet spot is root cause plus minimal context, not a reconstructed call graph in a string.

Anti-pattern 2: Abstraction Leakage

The service layer imports database/sql and branches on errors.Is(err, sql.ErrNoRows). This works until you swap Postgres for MongoDB — at which point it silently breaks with no compile error and no test failure. Business logic is now coupled to an infrastructure package it should never know about.

The fix is one rule: translate at the boundary. The persistence layer returns ErrProductNotFound — a domain sentinel. Nothing above it ever sees a driver type.

Anti-pattern 3: External Provider Leakage

A POST /checkout response returns Stripe’s request_id, charge=ch_3RJX..., and decline_code=insufficient_funds directly to the browser. That’s a security exposure — it fingerprints the payment processor and enables correlation of payment attempts across users. It’s also a portability trap: every client parsing those field names breaks the moment you switch providers.

The fix: StripeError never leaves the persistence package. It’s translated into a domain PaymentError{Code, Retryable}. Switch from Stripe to Braintree and you update one translation function inside persistence. Nothing above it changes.

The principle

Three patterns, one underlying idea: Preserve meaning as errors cross boundaries. Reduce coupling between layers. Enforce architectural clarity — every layer’s error types are a contract, not an implementation detail.

Simplicity does not scale automatically. Go gives us simple primitives. Architecture decides whether they stay simple as the system grows.

What you’ll take away

  • Why return fmt.Errorf("...: %w", err) helps until it doesn’t
  • How to find the wrapping sweet spot: enough context to trace, not enough to reconstruct the call graph
  • Why sentinel errors at layer boundaries are an architectural commitment, not a style preference
  • How to keep third-party error schemas — database drivers, payment providers, external APIs — out of your domain
  • A pattern for carrying structured error context (message + status code) that both backend and frontend teams can depend on