Zum Hauptinhalt springen
Illustration: blueprint-style iceberg on a dark grid, narrow tip labeled INTERFACE above the waterline, deep gear and pipework body labeled IMPLEMENTATION below
Blog··Updated:

Good Taste in Software Design: Judgment You Can Test

Taste is judgment about tradeoffs. Five design checks make that judgment explicit, expose weak assumptions, and give a team evidence to decide with.

Portrait of Antonio Agudo

Written by

Antonio Agudo

In-house enablement for engineering teams · two years running AI in production · Köln

In one codebase I worked on, five layers of abstraction pushed the average pull request from four changed files to thirteen. None of those layers did enough work to justify its existence.

That is a more useful starting point for discussing taste than whether the code looked elegant. The design's costs showed up in ordinary work.

Taste is judgment about which tradeoffs fit a problem. We can make that judgment less vague by examining what a design hides, how callers can misuse it, and what happens when it changes or fails. The checks produce evidence. The choice stays with us.

Taste picks values, practice sharpens the picking

The argument I build on comes from Sean Goedecke's essay on taste. He describes your engineering taste as the set of engineering values you find most important, and good taste as "the ability to select the right set of engineering values for the particular technical problem you're facing." To develop it, he recommends working on a variety of things and paying close attention to which parts turn out easy and which turn out hard. He acquired his own slowly but sees no reason it couldn't be acquired fast.

I agree. What stays open is the method, because "pay close attention" is sound advice that is hard to put on a calendar. This article proposes a deliberate practice: a few checks you run against a design, so the tradeoffs get written down, challenged, and later compared with what actually happened.

"Check" covers three different things:

  • A probe is a question that exposes a weak assumption. "Can you explain this design on one page?" measures nothing. It shows where your understanding runs out.
  • An observation is something you count or time, like the files a change touched. It only means something next to a defined scope and a comparison.
  • An executable check is a test that fails when an invariant breaks. It is the strongest evidence and covers the narrowest ground.

Most design decisions only ever get probes. That is fine as long as nobody mistakes a probe for proof.

Cost of change is where judgment shows

Amazon's 2015 shareholder letter separates Type 1 decisions, which are hard to reverse, from Type 2 decisions, which are cheap to undo. In that framing, database schemas, public APIs, and event formats usually behave like Type 1, while internal implementations and flag-guarded features behave like Type 2. Deliberation belongs on the first kind, speed on the second.

Deferring a Type 1 commitment works best with a stated trigger: "We version the public event schema once three external consumers depend on it and it has been stable for two months." Those numbers are the team's assumption, written down so someone can revisit it.

Splitting a three-user internal tool into services on day one commits a team to network boundaries, separate deployments, and distributed failure modes before anyone knows how the tool will be used. That commitment is expensive, hard to reverse, and made without evidence that it will pay off. Wrapping a vendor API behind an internal interface is the opposite trade: cheap now, and it keeps the option to switch vendors.

Five anchors, each with a check and a limit

1. Conceptual integrity

Fred Brooks called conceptual integrity "the most important consideration in system design." A system built around one coherent set of ideas is easier to use, extend, and debug. Keeping it that way means declining features that don't fit, even when each one is reasonable on its own.

Check (probe): For changes with meaningful design choices, write a one-page brief before committing to an approach: goals, non-goals, core concepts, three usage examples. Hand it to someone outside the project. Their confusion may reveal a problem in the design, the explanation, or both.

Limit: The one-pager favors designs that are easy to narrate. Some correct designs are dense by nature, a consensus protocol for instance. Density on the page is acceptable. Contradictions are not.

2. Deep modules

John Ousterhout's lecture notes on modular design set the goal for an interface as maximizing functionality relative to interface complexity. A deep module hides a lot of work behind a small surface. The same notes flag call stacks where one method simply calls another, which describes the five layers from the opening fairly well.

Check (probe, then observation): List the decisions callers no longer need to understand. Then rehearse a likely change on a throwaway branch and see whether those decisions stay inside the module. If you count touched files, record the scope (which directories, tests or not) and compare with similar past changes. The four-to-thirteen figure was at least a same-codebase comparison, though I can't reconstruct its exact scope today.

Limit: Ratios mislead here. Lines of code per public method improve whenever someone adds implementation behind an unchanged interface, whether or not the design got better, and Ousterhout's notes say size isn't the most important metric. A shallow, specialized path is sometimes right, usually for performance. Measure it, write down why, and keep it behind a narrow API.

3. API discipline

Joshua Bloch's standard for a good API: easy to learn, easy to use without documentation, hard to misuse. Hyrum's Law adds that with enough users, every observable behavior gets depended on. Together they argue for exposing little and treating public behavior as a commitment that may be costly to change. JSON fields, Kafka topics, database columns, and protobuf messages collect dependents the same way, so prefer additive changes and set a deprecation policy you can verify, such as two releases plus telemetry showing near-zero usage.

Check (probe, then executable): Before implementing, write code that uses the API and try to misuse it. Swap arguments, construct invalid states, ignore a failure. For each mistake, decide what catches it: the type structure, a constructor that rejects bad values, or documentation. Those are three different strengths of guarantee.

A payments client shows the difference:

// Before (signatures only)
class Payments(private val http: HttpClient) {
    fun charge(userId: String, amount: Double, currency: String): Boolean
    fun refund(userId: String, amount: Double, currency: String): Boolean
}

Double rounds money. Three strings in a row let a caller swap user and currency unnoticed. Boolean discards why a call failed. Nothing makes a retry after a timeout safe, and refund takes a user instead of the charge it reverses. Because callers depend on a concrete class wired to HTTP, their tests tend to end up faking HTTP traffic rather than payment outcomes.

// After
@JvmInline value class UserId(val value: String)
@JvmInline value class ChargeId(val value: String)
@JvmInline value class RefundId(val value: String)
@JvmInline value class IdempotencyKey(val value: String)

enum class Currency { USD, EUR, GBP } // all three use two decimals

@ConsistentCopyVisibility
data class Money private constructor(val amount: BigDecimal, val currency: Currency) {
    companion object {
        fun of(amount: BigDecimal, currency: Currency): Money {
            require(amount.signum() > 0) { "Amount must be positive" }
            require(amount.stripTrailingZeros().scale() <= 2) { "At most two decimals" }
            return Money(amount.setScale(2), currency)
        }
    }
}

sealed interface ChargeResult {
    data class Success(val chargeId: ChargeId) : ChargeResult
    sealed interface Failure : ChargeResult {
        data object InsufficientFunds : Failure
        data object RateLimited : Failure
    }
}

sealed interface RefundResult {
    data class Success(val refundId: RefundId) : RefundResult
    sealed interface Failure : RefundResult {
        data object ChargeNotFound : Failure
        data object CurrencyMismatch : Failure
        data object ExceedsRefundableAmount : Failure
    }
}

interface Payments {
    /** A repeated key for the same user within 24 hours must return the original result. */
    fun charge(user: UserId, amount: Money, key: IdempotencyKey): ChargeResult

    /** Partial refunds allowed. A repeated key for the same charge within 24 hours must return the original result. */
    fun refund(charge: ChargeId, amount: Money, key: IdempotencyKey): RefundResult
}

Each improvement has a different strength:

  • Swapped arguments become compiler errors, because user, charge, refund, and key have distinct types.
  • Invalid amounts (non-positive, or finer than cents) are rejected when Money is constructed. That is a runtime check at the boundary, not a compile-time one.
  • Failures are declared. The result types make each failure case explicit and support exhaustive handling in a when expression. They don't force a caller to look at the result.
  • Idempotency is a contract the interface states, not something the key parameter enforces. Implementations have to store results per key and scope, reject a repeated key with different arguments, and never execute twice when retries arrive concurrently.

The interface is also a seam, so tests can run against an in-memory implementation. That is where executable checks earn their place. A property test with Kotest, against an in-memory implementation not shown here:

class RefundProperties : StringSpec({
    val eur = Arb.long(1L..1_000_000L).map { Money.of(BigDecimal.valueOf(it, 2), Currency.EUR) }
    val user = UserId("user-1")
    fun newKey() = IdempotencyKey(UUID.randomUUID().toString())

    "successful refunds never add up to more than the charge" {
        checkAll(eur, Arb.list(eur, 1..10)) { charged, requests ->
            val payments = InMemoryPayments()

            // Precondition: this property is about refunds, so a failed charge is a broken fixture
            val chargeId = when (val result = payments.charge(user, charged, newKey())) {
                is ChargeResult.Success -> result.chargeId
                is ChargeResult.Failure -> fail("Fixture charge failed: $result")
            }

            var refunded = BigDecimal.ZERO
            for (request in requests) {
                if (payments.refund(chargeId, request, newKey()) is RefundResult.Success) {
                    refunded += request.amount
                }
            }
            refunded shouldBeLessThanOrEqualTo charged.amount
        }
    }
})

The property has a blind spot: an implementation that rejects every refund passes it. Pair it with an example test showing that a full refund of a fresh charge succeeds.

Limit: Every wrapper type is one more concept a caller has to learn. For money, four value classes are cheap insurance. For an internal helper with two callers, they may be ceremony.

4. Simplicity over convenience

Rich Hickey distinguishes simple, meaning untangled, from easy, meaning familiar or close at hand. Convenient shortcuts feel productive the day they land. The concepts they tangle keep charging interest.

Check (probe): When a design makes two concepts depend on each other, ask what would have to change to separate them. A long answer marks where complexity will pile up.

Limit: Separation costs something too: more modules, more interfaces, more indirection. The five layers from the opening were separation without enough work behind each seam. This anchor and the previous one pull against each other, and no check settles that for you.

5. Operational behavior

Design includes how code behaves in production: telemetry at decision points, timeouts for slow dependencies, idempotency keys with a defined scope and retention, a rollback path, logs free of secrets and unhashed user identifiers, and deny-by-default checks on cross-service input.

Check (probe): Walk through a failure before it happens: a slow dependency, a malformed input, a bad deploy. Can you tell from logs and metrics which caller, which input, and which dependency? Can you switch the feature off without deploying?

Limit: Every flag is a code path someone has to test and eventually remove. Kill switches on everything tend to become untested configuration. Reversibility is worth its cost where failure is expensive.

Case study: a metrics pipeline for 13 teams

This is a real project, anonymized. The figures come from our own tracking over two quarters. They are small samples, not a controlled comparison.

The starting point

Thirteen teams posted JSON with arbitrary structure to a shared metrics service, which routed it to Datadog, CloudWatch, or Prometheus based on configuration files spread across repositories. A backend change, such as adding a new backend, meant editing 15 of those files. Malformed payloads crashed workers. Incidents typically started with the same three questions: which team, which metric, which backend. Over Q1 we tracked 10 incidents, with a mean time to resolve of 2h07m.

The tradeoff

Two things we valued pulled in opposite directions. Arbitrary JSON was cheap for the teams: no schema to agree on, no coordination to add a metric. It was expensive for whoever operated the pipeline, because nothing about a payload was known until it broke something. A schema reverses that. Each of the 13 teams now has to send metrics in a typed shape, under naming rules the platform sets.

We chose operability and accepted the costs that came with it:

  • The schema became a public API. Thirteen teams depend on MetricSchema, so Hyrum's Law now applies to it. Tightening the naming pattern later would break callers.
  • One shape serves three backends. Anything backend-specific has to fit through the shared value types or stay out. In the design below, a histogram arrives as raw values, so bucket choices sit in the translation layer rather than with the team that knows the data.
  • Two engineers spent three weeks on it.

What changed

We wrote the core idea on one page, "type-safe metrics with declarative routing," shared it with five teams, and revised it from their feedback. Eight public configuration formats became one interface, MetricSchema. Callers no longer need to know which backend receives a metric, how each backend's format works, or how failed sends are retried. Schema definition, routing policy, and backend translation became separate modules.

sealed class MetricValue {
    data class Counter(val value: Long) : MetricValue()
    data class Gauge(val value: Double) : MetricValue()
    data class Histogram(val values: List<Double>) : MetricValue()
}

data class MetricSchema(
    val name: String,
    val tags: Map<String, String>,
    val type: MetricValue
) {
    init {
        require(name.matches(Regex("[a-z0-9._]+"))) { "Invalid metric name" }
        require(tags.all { it.key.matches(Regex("[a-z0-9_]+")) }) { "Invalid tag key" }
    }
}

The sealed MetricValue makes some mistakes unrepresentable: a counter can't carry a list of samples. The name and tag rules work differently. Initializer blocks run when an instance is created, so an invalid name fails at runtime, wherever the object is built. An earlier version of this article said these errors were caught at compile time. The code doesn't support that. A validation endpoint let teams hit that failure before deploying.

We also added per-backend feature flags, structured logs tagged with team, metric_name, backend, and error_type (no personal data), and metrics for published sends, failures by reason, and backend latency. Rollout started as a dark launch on 5% of traffic for a week and ramped to 100% over two more weeks, with a kill switch in the admin panel.

What we observed

MeasureBeforeAfter
Mean time to resolve tracked incidents2h07m (10 incidents, Q1)15 minutes (8 incidents, Q2)
Files edited for a backend change15 configuration files1 file
Worker crashes from malformed payloadsrecurringnone recorded in Q2

These numbers show what changed, not why. Validation, routing, logging, metrics, and rollout controls all changed at once, so no single row isolates a cause. The rows also count different things: the crash row covers worker crashes, not only the incidents in the first row. My reading is that structured logs did most of the work on resolution time, because the three questions incidents used to start with became fields in a log line. The backend figure is structural rather than statistical, since backend-specific translation now sits behind one interface. And eight and ten incidents from two different quarters are small numbers, with nothing guaranteeing they were comparable.

A worksheet, not a score

Adding ratings into a total is tempting, and it fails in a specific way. Sum a set of ratings and a design with the worst possible misuse resistance can still look ready to ship, because strong ratings elsewhere outweigh it. A good rollout plan doesn't make a data-corrupting API acceptable.

A worksheet keeps each dimension visible. For every dimension that matters to this change, record the evidence, the uncertainty, the risk you accept, and what would make you rethink. Two rows for the metrics redesign, as I would fill them in today:

DimensionEvidenceUncertaintyAccepted riskRethink when
Misuse resistanceSealed value types, name checks at construction, validation endpoint before deployWhether the naming pattern fits every team's existing metricsInvalid names fail at runtime, not at compile timeTeams start working around the naming rules
DepthEight config formats replaced by one interface. A backend change touches one fileBackend features the shared value types can't expressHistogram buckets decided centrallyA team needs a backend feature the schema can't carry

Serious risks stay out of the rows. Answer them first, and give any "yes" its own resolution (a fix, or a named person accepting it in writing), however strong the rest looks:

  • Can a caller corrupt or lose data through the API as designed?
  • Do logs or traces carry secrets or unhashed user identifiers?
  • Is there no way to disable or roll back once it is live?
  • Does a cross-service call accept input it hasn't verified?

The routine

The checks from the anchors combine into one routine. Scale it to the decision: a new public API gets all of it, a private refactor maybe two steps.

  1. Before choosing an approach: write the brief, list what callers won't need to understand, misuse the API from the caller's side.
  2. During review: rehearse one likely change with a recorded scope, walk through one failure, ask which behavior you would be stuck with at ten times the usage, then fill in the worksheet and answer the serious-risk questions.
  3. After it ships, and after incidents: compare the worksheet with what happened. Which uncertainty turned out real? Which trigger fired? Which abstraction was too shallow, which decision leaked? Aim the answers at design choices, not people.

The third step is where Goedecke's advice to notice what turned out hard actually happens. Without a written prediction, that noticing relies on memory, and memory favors the decisions we are proud of.

For a team, the routine lives in artifacts: a brief template, the misuse and failure questions in the pull request template, the worksheet stored next to the design document so the postmortem can find it. Keep guidelines phrased as prompts. "Check whether the responsibilities belong together" starts a conversation. "If a class has 'and' in its name, split it" produces renamed classes. In a legacy system, start with one seam: wrap it, hide one volatile decision behind it, and watch whether the next requirement touches fewer call sites.

Your next design review

None of this turns judgment into arithmetic. The anchors conflict with each other, depth against separation and reversibility against simplicity, and the metrics case shows that a good decision still leaves costs for someone to carry. What the checks change is where the judgment is recorded: in a document a colleague can argue with.

Before your next design review, write down the tradeoff you are making, the evidence behind it, and what would make you change your mind.


Working with coding agents? Design judgment gets harder to hold onto when an agent writes much of the code. The in-house enablement for engineering teams covers how to build checks like these into agent workflows.


Further Reading

Continue reading

Next step

Interested in AI training for your engineering team? Mastering Coding Agents is an in-house enablement: an assessment first, three days of training on your own code, then 30 days of transfer. After six weeks you see in your own data what changed.

Read here regularly? You can mark antonioagudo.com as a preferred source on Google so this content stays more visible in your results.