logoChibiham
cover
⚖️

Structure and Behavior: Achieving Agility and Quality with Types and Tests

Introduction: Can Agility and Quality Coexist?

"What's the impact scope of this change?"—a question frequently asked during code reviews. You answer "Let me check," then spend half a day tracing related code and reviewing tests. Or you say "Should be fine" and end up causing a production incident.

Prioritize speed and quality suffers; pursue quality and development slows down. This dichotomy is taken for granted in many development teams. However, agility and quality can coexist through proper design.

Here, agility doesn't mean simply "writing code quickly." It means speed of response to change. When requirements shift, when bugs surface, when new features need to be added—how fast can you turn that feedback loop? This is the essence of agility.

The key to accelerating this feedback loop lies in having domain cognitive structures directly reflected in code. When domain concepts and code structure align, the intent "I want to change this" directly translates to "I need to modify this code." There's no translation cost between concepts and code.

This article proposes a two-layer structure for achieving this "isomorphism between domain and code": expressing structure through types and verifying behavior through tests.

Structure and Behavior: Two Aspects of a Codebase

Let's first clearly distinguish between two aspects that constitute a codebase.

What is Structure?

In this article, "structure" refers to the totality of constraints arising from connections, dependencies, and boundaries between concepts.

  • Dependencies and boundaries between modules
  • Invariants arising from concept coupling (e.g., "A shipped order must always have a payment date")
  • Technical constraints (protocols, data formats, etc.)

The key point here is that we're focusing not on internal constraints of individual concepts (simple validations like "amount must be >= 0"), but on constraints arising from relationships between concepts.

What is Behavior?

"Behavior," on the other hand, refers to output for a given input, or state changes resulting from operations.

  • The response when sending a request to an API endpoint
  • The return value when passing arguments to a function
  • The system state after performing an operation

The Relationship Between the Two

Structure and behavior are not independent. Structure constrains behavior.

For example, if there's a structural constraint that "a shipped order must always have a payment date," then the behavior "get payment date of a shipped order" always succeeds (no null check needed). Conversely, without this structural constraint, the behavior implementation requires null-check branching, increasing test cases.

Good structure simplifies behavior implementation and reduces the cases that need verification.

Expressing Structure Through Types

Make Illegal States Unrepresentable

Type systems are a means to express relationships and constraints between concepts at the code level. Following the Make Illegal States Unrepresentable principle, type definitions themselves reflect domain structure.

typescript
// Weak structure — illegal states are representable
type Order = {
  status: string;
  paidAt: Date | null;
  shippedAt: Date | null;
};
// Allows illegal state where status is "shipped" but paidAt is null

// Strong structure — illegal states eliminated at compile time
type Order =
  | { status: "draft" }
  | { status: "paid"; paidAt: Date }
  | { status: "shipped"; paidAt: Date; shippedAt: Date };
// Required fields for each state are enforced at the type level

With the latter design, there's no need to verify through tests that "shipped orders have payment dates." The compiler eliminates such states.

Type Definitions Become the Domain Model

Taking this idea further, type definitions themselves become the expression of the domain model. The concepts used when talking with domain experts—order, payment, shipment—are defined directly as types, and their relationships are expressed as type structure.

This is the state where "domain cognitive structures are directly reflected in code." Because domain language and code language match, it becomes clear which code is affected by requirement changes. This is why feedback loops become faster.

Verifying Behavior Through Tests

Implementation Details vs. Interfaces

Tests should be responsible for verifying behavior as it appears at interfaces. There's an important distinction here.

Tests coupled to implementation details break when structure changes through refactoring. However, this is not a limitation of testing as a technique, but a problem with how tests are written. If you write tests against the interface a module exposes to the outside world rather than its internal implementation, they won't break during internal structure refactoring.

In other words, the target of tests should be behavior as it appears at module interfaces, and tests against individual internal implementation functions are fragile to structural changes.

Fractal-like Interface Testing

The principle of "test against interfaces" can be applied at all granularities.

Fractal-like interface testing

Types play two roles here. One is defining the shape of interfaces—declaring what each boundary receives and returns through function signatures, request/response types, etc. The other is ensuring structural consistency of internal implementations—the Make Illegal States Unrepresentable principle mentioned earlier.

Tests verify behavior on top of type-defined interfaces. Dynamic properties like "this input produces this output" cannot be expressed by types alone.

This structure repeats in a nested fashion at all scales.

However, note that E2E tests are somewhat different in nature. While module tests and integration tests verify contracts against single interfaces, E2E tests verify entire user journeys spanning multiple modules. They share the common aspect of "verifying behavior against boundaries," but their purposes and technical setups differ.

Constraints That Can't Be Expressed by Types

Not all structural constraints can be expressed through types. For example, the constraint "orders can only be created for products that are in stock." This is an invariant arising from concept coupling, but because it depends on runtime state, it can't be fully expressed through types alone.

For such constraints, verifying them as tests at the boundary interface where the constraint manifests is appropriate.

Type of ConstraintVerification MethodExample
Structural constraint between concepts (static)Type systemShipped orders must have payment date
Constraint depending on runtime stateInterface testOnly products in stock can be ordered

These two are not mutually exclusive but complementary.

Design Process: Back-and-Forth Movement and Sequence

Let's consider one question here. When translating design into code, which should we think about first—structure or behavior?

Thinking is Back-and-Forth Movement

In actual design processes, thinking about structure and behavior goes back and forth.

  • Thinking "I want to implement this behavior" leads to realizing "this concept should be separated"
  • Conversely, deciding "let's use this structure" reveals "this behavior will be constrained in this way"

Enumerating use cases reveals necessary entities, and organizing entity relationships constrains possible operations. This back-and-forth is an essential part of the design process.

The Sequence for Code is "Structure -> Behavior"

However, at the stage of writing code, the sequence "structure first, behavior second" is effective.

The reason is clear. When you define types (structure) first, you can only write behavior that conforms to those types. The compiler functions as a guardrail, preventing implementation of incorrect behavior.

Conversely, if you start with behavior, the structure tends to remain unstable as you progress. When you later fix the structure, you'll need to substantially rewrite the behavior code you've already written.

Getting Design Feedback by Writing Types

Another important point: by trying to write type definitions, you can verify the validity of abstract designs.

Even if you think "this concept should have this structure" in your head, you often notice contradictions and ambiguities when actually trying to write it as types. "Types are hard to write" is a signal that "the design is wrong."

This is a feedback loop from abstract design to concrete code, and turning this loop quickly improves design quality.

Good Structure Prevents Test Bloat

Let's examine the intuition that if structure is good, behavior patterns are limited, and tests don't bloat.

Eliminating Accidental Complexity

Fred Brooks, in his "No Silver Bullet" essay, divided software complexity into essential complexity (complexity inherent in the domain) and accidental complexity (complexity arising from technical choices or design deficiencies).

If types make illegal states unrepresentable, the state space that tests need to verify shrinks. As seen in the Order type example, the more types constrain the state space, the fewer test cases are needed. This is reduction of test cases arising from accidental complexity.

Localizing Essential Complexity

On the other hand, essential complexity inherent in the domain remains regardless of structure quality. For example, tax rate calculation—when tax rates vary based on product category, region, customer type, and campaign application, even if you clearly separate each concept with types, the combination patterns themselves don't decrease.

However, with good structure, this essential complexity becomes localized. It's confined within the tax calculation module and doesn't leak beyond its boundary. Tests might be numerous within the module, but from the perspective of the entire product, tests haven't bloated.

Test Bloat is a Signal of Structural Problems

Let's clarify the criteria for "good structure" here. Good structure is a state where domain concept structures are directly mapped to code. Concepts called "order," "payment," and "shipment" in the domain are defined as types as-is. The relationships between concepts ("shipment must be preceded by payment") are expressed as type structure.

By this criterion, "good structure" means clear separation of responsibilities between concepts, where each interface handles only limited concerns. Limited concerns produce limited input/output patterns.

Conversely, test bloat is a sign that one interface is taking on too many concerns. Test bloat is a signal of structural problems, not a problem with tests themselves.

Conclusion: A Two-Layer Strategy with Types and Interface Tests

Let's summarize the discussion.

Layer One: Structural Guarantees Through Types

  • Following the Make Illegal States Unrepresentable principle, eliminate illegal states at the type level
  • Express dependencies and boundaries between modules through types
  • The compiler functions as a guardrail

Layer Two: Behavior Verification Through Interface Tests

  • Verify behavior against each module's public interface
  • Verify runtime state-dependent constraints as integration tests at boundary interfaces
  • Perform interface tests at each layer's boundary (note that E2E differs in nature)

This two-layer structure enables both agility and quality.

  • Types expressing domain structure reduce the gap between concepts and code, making the scope of changes clear
  • Interface tests verifying behavior create a safety net that doesn't break during refactoring
  • Writing types provides design feedback; writing tests provides implementation feedback

"Build fast" and "build right" are not a trade-off. Through proper structure—type definitions that directly reflect domain cognitive structures and interface tests at each boundary—you can turn feedback loops quickly while maintaining quality.