API-DDDesign modules as APIs
Manifesto
Manifesto index

API-Driven Development manifesto

Modular software from its APIs.

A short manifesto about designing modules from the point of view of the code that uses them. It looks at contracts, names, coupling and tests.

API-Driven Development, or API-DD, is simply the name given to this way of looking at software.

5 foundations46 min readGo examplesOne chapter on AI

Introduction · Introduction

API-Driven Development: design each module as an API

From the origin of the term API to a perspective centered on modules and conversations.

In this chapter
  1. Where API comes from
  2. How API-DD reinterprets it
  3. Manifest
  4. Definition
  5. Design the API
  6. A small example
  7. Example in Go
  8. One way to apply it

Where API comes from#

API abbreviates Application Programming Interface. The term predates the Web, and its central idea is even older: allowing a program to use a capability without depending on how it is built.

YearMilestoneIdea that remains
1951Wilkes, Wheeler and Gill describe a subroutine library for EDSACThe consumer needs a known way to invoke reusable behavior
1968Cotton and Greatorex use application program interface in a remote graphics systemA stable interface can separate the program from different terminals and mechanisms
1974Date and Codd compare database programming interfacesThe design of the interface impacts the design of the entire system

The API, therefore, was not born as a synonym for an HTTP endpoint or a public service. Those are later mechanisms for expressing an interface. APIs are also the functions of a library, the calls of an operating system or any protocol that allows collaboration with an encapsulated capacity.

How API-DD reinterprets it#

API-DD preserves that historical function of the interface: separating the consumer from the mechanism. It expands that idea with an observation from Alan Kay: in a system capable of growing, it is more important to design how its modules communicate than to fix their internal properties. Kay placed message exchange at the core of Smalltalk, but his observation about modules and communication can be applied outside object-oriented programming (original message from 1998).

API-DD takes the API from the outer boundary of an application to every modular relationship worthy of a contract. The conversation can be local or remote and expressed with functions, methods, events or HTTP. The mechanism changes; the separation between consumer and implementation remains.

This is a deliberate reinterpretation, not the claim that API has always meant exactly the same thing. From its history we take the separation between use and implementation; from Kay, the focus on messages between modules. That's where API-Driven Development comes from.

Manifest#

The software changes. Its contracts allow it to change without forcing each consumer to know its interior again.

  1. Interactions matter more than the internal form. A module is understood by the conversations it offers and consumes.
  2. Vocabulary is part of the design. Names express intentions, results and facts that other modules can understand.
  3. Visibility creates coupling. The API shows what is necessary and keeps algorithms, coordination and representation replaceable.
  4. Autonomy extends to every result. A complete and valid module fulfills its contract without asking the consumer to repair its state, and delivers results that do not share mutable references.
  5. The contract can be verified from outside. Tests act as consumers and observe results, status or public messages.

These fundamentals can be applied recursively when a module is decomposed into other modules with their own contracts. API-DD does not prescribe an architecture, paradigm, or order of work. It offers a perspective for designing the conversations that hold the system together.

Definition#

API-Driven Development (API-DD) proposes designing each module as an API: making visible the messages it accepts, the guarantees it keeps, the details it hides and the APIs it needs.

The starting point is not the class, the folder or the pattern that we are going to use. It is the contract through which one module collaborates with the others. Before solving its internals, we clarify what API it offers and what commitments must survive any correct implementation. It is a design perspective, not an architecture or set of mandatory rules.

In this book, module does not necessarily mean a language module, a package, a deployable service, or a file. It is an encapsulated part of the software that others use under a contract. Depending on the scale, it can be realized as a set of functions, a type, a package, a process, or a combination of these.

TermUse in API-DD
ModuleEncapsulated part of software that provides behavior to others
ConsumerActor, system or module that depends on that behavior
APIProtocol by which a module relates to its consumers
MessageRequest, response or fact that crosses that API
SupplierImplementation that satisfies the contract

An API can be expressed with functions, methods, events, endpoints, or any other mechanism. The important thing is the conversation, not its syntax.

Design the API#

Looking at a module as an API makes few but concrete questions visible:

  1. Who consumes it and why?
  2. What messages can you send it?
  3. What results, errors, state or effects can you observe?
  4. What guarantees are maintained between calls?
  5. What details should remain hidden?
  6. What other APIs does the module consume?

These answers form the contract. The signature is only its most visible representation.

A small example#

Let's assume a module that applies a style to a text:

text
Formatter.Format(Text, Style) → FormattedText | UnsupportedStyle

Formatter is the module and Format is a message from its API. Text, Style, FormattedText and UnsupportedStyle form the contract vocabulary. It is important to specify what they mean and what the consumer can do with them, but they are not independent APIs simply because they appear in the signature.

The contract may promise that:

  • a supported style produces formatted text;
  • an unknown style returns a distinguishable error;
  • the original text does not change;
  • the result does not reveal the library used internally.

The consumer does not need to know whether the provider uses a template, an intermediate tree, or an external library. Those decisions may change as long as the guarantees are maintained.

Example in Go#

Go can express the conversation using an interface and a distinguishable error.

go
import "errors"

type Text string
type Style string
type FormattedText string

var ErrUnsupportedStyle = errors.New("unsupported style")

type Formatter interface {
    Format(Text, Style) (FormattedText, error)
}

The representation could change as long as Formatter, Format and the meaning of ErrUnsupportedStyle retain the contract.

One way to apply it#

A possible sequence to design a change with API-DD:

  1. Identify the affected modules and their consumers.
  2. Describe the conversation that each consumer needs.
  3. Defines messages, vocabulary and observable guarantees.
  4. Separate the contract from internal decisions.
  5. Repeat the analysis only for internal modules with their own relationships.
  6. Implement and test the contract without setting an unnecessary internal path.

API-DD is not a substitute for domain modeling, architecture, or TDD. It helps to specify how the modules that these disciplines discover collaborate.

API-DD allows you to view each module as an API and keep its implementation free.

I · Foundations · Foundation 1 of 5

Foundation 1. Recursion

Apply the same perspective to each module and focus on its interactions.

In this chapter
  1. An API can be conceptualized as a module
  2. Interactions are the focus
  3. All fundamentals are repeated
  4. Where to continue and where to stop
  5. Example in Go
  6. Hands-on review

An API can be conceptualized as a module#

To design an API, it is useful to conceptualize it as a module seen from the outside. The module assembles a capability and preserves its implementation; the API is the boundary by which others collaborate with it.

They are not exactly the same. A module can offer more than one conversation to different consumers and also consume other APIs. Equivalence serves as a design tool: when a relevant API appears, we look for the module responsible for supporting its contract.

text
module
├── API offered → consumers
├── hidden implementation
└── Consumed APIs → other modules

The module can be materialized as a function, a type, a package, a process, or several coordinated elements. Its technical form does not determine its conceptual scale.

Interactions are the focus#

API-DD focuses on what happens between modules: messages, responses, errors, effects and guarantees. This look follows Alan Kay's idea presented in the introduction: systems grow better when you design how their modules communicate, not when their entire interior is fixed in advance.

Looking at the interactions allows you to ask specific questions:

  • What does the consumer need to express?
  • What module is responsible for that capacity?
  • What can be observed on the other side of the limit?;
  • What conversation does the module have with its own suppliers?;
  • What decisions can be changed without affecting others?

The algorithm is still important, but it belongs to another level. First we distinguish what must survive any correct implementation and then we choose how to achieve it.

All fundamentals are repeated#

When a module is decomposed into modules with their own contracts, the five fundamentals can be applied again at each boundary:

FoundationQuestion that reappears
RecursionWhat modules and conversations exist at this scale?
VocabularyWhat do their names and messages mean?
VisibilityWhat does each consumer need to know?
AutonomyCan the module fulfill its contract and deliver results without shared mutable state?
TestabilityCan it be verified by public observations?

Recursion does not convert every parameter, helper, or structure into another API. Value, Result, or Error are part of the vocabulary of a message. They are only considered modules when they gather behavior, have consumers or need to evolve through their own contract.

Where to continue and where to stop#

The perspective can be applied again when at least one of these signs appears:

  • another consumer needs to use the capacity directly;
  • there is a responsibility with its own guarantees;
  • the component can evolve or be replaced independently;
  • an interaction crosses a relevant technical or organizational boundary.

It stops when the decision only explains how the current module works. A loop, index, or helper function does not need its own API if no external relationship depends on it.

This avoids confusing recursion with an infinite hierarchy of interfaces. The goal is to recognize useful boundaries, not to fabricate layers.

Example in Go#

A Pipeline offers an API and consumes the API of each Stage. The slice traversal remains within its implementation.

go
type Stage interface {
    Apply([]byte) ([]byte, error)
}

type Pipeline struct {
    stages []Stage
}

func (p Pipeline) Run(value []byte) ([]byte, error) {
    var err error
    for _, stage := range p.stages {
        value, err = stage.Apply(value)
        if err != nil {
            return nil, err
        }
    }
    return value, nil
}

There may be many implementations of Stage. Each can be reviewed again as a module, while the slice path remains a Pipeline detail.

Hands-on review#

  1. Name the API you are looking at and the module that answers for it.
  2. Identify your consumers and the APIs they consume.
  3. Repeat the analysis only in collaborations with their own contract.
  4. Keep algorithms and helpers within the module that uses them.
  5. Check that the decomposition clarifies a real relationship.

Recursion follows conversations between modules, not each line of code.

I · Foundations · Foundation 2 of 5

Foundation 2. Vocabulary

Name modules, messages and values from the conversation the consumer needs.

In this chapter
  1. Design the vocabulary
  2. Example: Add a task to a queue
  3. Example in Go
  4. Exported names
  5. Good names
  6. The module
  7. The messages
  8. Values ​​and results
  9. Booleans, errors and events
  10. Communication
  11. Context avoids repetitions
  12. Find a name
  13. Renaming is migrating

Design the vocabulary#

A name does not decorate a finished solution. Decide what concept the consumer will see and what they can expect from it.

When an API uses words like execute, data, or response, it forces you to read the implementation to discover their meaning. When you name an intention, a result, or a recognizable situation, it allows you to understand the contract without opening up the implementation.

The vocabulary of an API should remain true even if its implementation changes.

Every value that crosses an API needs a clear meaning. Normally it is enough to answer these questions:

  • what does it represent?
  • what values ​​are valid?;
  • how does it compare?
  • can it be absent?;
  • who preserves its mutable state?;
  • What representation can the consumer observe?

The answer doesn't always require a new type. A proper type contributes when it names a difference, protects an invariant, or prevents an invalid combination. If the context already avoids confusion and the rules are the same, separating two values ​​only adds ceremony.

Example: Add a task to a queue#

This signature mainly describes the organization of the code:

text
QueueService.Execute(EnqueueCommand) → QueueResponse

We don't know what is executed, what the response contains, or what can go wrong. The same capacity can be expressed like this:

text
Queue.Enqueue(Task) → Position | QueueFull

Each word fulfills a function:

  • Queue names the module and provides context;
  • Enqueue expresses the consumer's intention;
  • Task names the value that is entered;
  • Position explains the result;
  • QueueFull identifies a situation in which the consumer can act.

The implementation can use memory, files, or a remote system. None of these decisions require changing the message.

Example in Go#

The example keeps Queue, Enqueue, Task, Position, and QueueFull as visible vocabulary.

go
import "errors"

type Task struct{ ID string }
type Position int

var ErrQueueFull = errors.New("queue full")

type Queue interface {
    Enqueue(Task) (Position, error)
}

Exported names#

Public names are the vocabulary shared between a module and its consumers. They deserve more stability than internal identifiers because they appear in calls, documentation, tests, and sometimes serialized data.

Each language expresses that border in another way. In Go, an exported identifier begins with a capital letter; lowercase names remain within the package. A internal directory also allows you to limit which part of the tree a package can import.

Exporting does not improve a name or turn a type into a good abstraction. First, what the consumer needs to name is identified; then the corresponding visibility is given. Helpers and internal representations can use more technical words without contaminating the public conversation.

Good names#

The module#

The name of a module should indicate the capacity it has, not the pattern with which it was built.

Manager, Service, Handler, Facade, or Helper are usually weak when appearing alone. They classify a technical structure, but do not explain what it offers. QueueManager, for example, allows you to imagine almost any responsibility; Queue sets a specific context for messages such as Enqueue, Next, or Remove.

This does not make technical names a universal mistake. In composition it may be useful to distinguish MemoryQueue from RemoteQueue, because there the consumer chooses an implementation. The API they both provide can still be called Queue.

The useful question is: does the consumer need to know about this mechanism? If the answer is no, the technical name belongs to the interior.

The messages#

Messages express intentions or facts, not internal steps.

A verb like Enqueue allows you to anticipate the effect. Run, Execute, Process, or Handle are only accurate when executing, processing, or dispatching is actually the capability of the module.

The simplest test is to read the usage as a sentence:

text
position = queue.Enqueue(task)

If to understand it you have to translate framework categories, the contract still speaks from the implementation.

Values ​​and results#

A type deserves a name when it represents a difference that matters. Task, Position, and Capacity say more than Data, Item, or ValueObject, as long as those are the words in the actual context.

Suffixes that repeat the technical category are usually superfluous:

text
TaskModel
PositionValueObject
QueueResponseDTO

They may be needed within an adapter that translates two representations, but they should not be propagated to the main API by accident.

Units are also part of the meaning. Delay may be ambiguous if the consumer needs to distinguish milliseconds from seconds. A longer name is not always necessary; Yes, it is necessary to preserve the difference that prevents incorrect use.

Booleans, errors and events#

A boolean is best understood as a proposition:

text
queue.IsFull()
queue.Contains(taskID)

Check, Flag, or Status do not clarify what true means. If there are more than two relevant results, the contract probably needs a named state instead of a boolean.

Public errors describe situations to which the consumer can react. QueueFull allows you to wait or choose another queue. DatabaseError leaks infrastructure and may not offer any useful decisions. Technical details can be preserved as internal cause, log or diagnosis without becoming stable vocabulary.

An event names an event that has already occurred. TaskQueued avoids confusing the notification with the EnqueueTask request. The past also helps the name remain true even if the transportation changes.

Communication#

Vocabulary works as a system, not as a list of isolated terms. Module, message, arguments and results must be able to be read together as a conversation.

Context avoids repetitions#

Precision is not about including the entire explanation in each identifier:

text
queue.Enqueue(task)

It is clearer that:

text
queueService.ExecuteTaskEnqueueCommand(taskValueObject)

In the first case, module, message and argument distribute the meaning. In the second, the technical categories add length without better explaining the contract.

A short name can be ambiguous and a long one can compensate for a poorly chosen context. The goal is to use minimal words that preserve the meaning where they are read.

Find a name#

The name usually appears when first describing the conversation:

  1. Write a sentence from the consumer: "I want to add this task to the queue."
  2. Separate the context, intention, values ​​and situations that change behavior.
  3. Check what words people who know the problem use.
  4. Read an entire call and remove what the context already says.
  5. Imagine another implementation and check that the vocabulary is still true.
  6. Review each error or event that the consumer should distinguish.

If it is difficult to name a module, there may be mixed responsibilities or premature abstraction. In that case, it is advisable to return to the conversation before looking for a more elegant synonym.

This practice coincides with the ubiquitous language of DDD: names gain precision within an explicit context, not in a universal dictionary (DDD Reference, Eric Evans). API-DD adds a concrete check: the vocabulary must work from the consuming module and survive alternative implementations.

Renaming is migrating#

Once published, a word is part of the contract. Changing it may break compilation, serialized messages, documentation, metrics, or integrations.

A migration may require entering the new name next to the old one, adapting consumers, and removing the alias when the dependency no longer exists. Just because the new name is better does not make the change harmless.

A good name explains the conversation and does not reveal the mechanism that makes it possible.

I · Foundations · Foundation 3 of 5

Foundation 3. Visibility

Show what the consumer needs and keep the implementation replaceable.

In this chapter
  1. Visibility and coupling
  2. What the consumer can observe
  3. Example: a set without duplicates
  4. Example in Go
  5. Public API and implementation
  6. Status and effects
  7. Repetition and idempotence
  8. Compatibility
  9. Hands-on review

Visibility and coupling#

An API is the protocol that allows two modules to collaborate. Your contract gathers the minimum information a consumer needs to use it without knowing its implementation.

A signature can show names and types:

text
Set.Add(Value) → AddResult

But it doesn't by itself explain whether duplicates are allowed, how a value is compared, what changes after the call, or what will happen when the call is repeated. These guarantees also belong to the contract.

Each visible element creates a dependency: the consumer can use it and the provider will have to keep or migrate it. Reducing visibility decreases coupling only when the API continues to express all necessary capability.

What the consumer can observe#

PartQuestion
ConsumersWho uses the API?
MessagesWhat can you ask or communicate?
VocabularyWhat do inputs, results, errors and events mean?
ValidityWhat values ​​and sequences are accepted?
GuaranteesWhat result, state or effect is preserved?
VisibilityWhich consumers can access the contract?
CompatibilityWhat previous uses should still work?

Not all APIs need to document each dimension in the same detail. The depth depends on the risk and the number of consumers. A local module with pure operation can be made clear with a signature and two examples; a shared protocol will need more precision.

Example: a set without duplicates#

Let's consider this standalone API:

text
Set.Add(Value) → Added | AlreadyPresent
Set.Contains(Value) → bool
Set.Size() → integer

Your contract can be expressed with four guarantees:

  1. adding a missing value returns Added;
  2. after adding it, Contains returns true;
  3. adding the same value again returns AlreadyPresent;
  4. repeating the operation does not increase Size.

The contract still needs a decision on equality: when do two values ​​represent the same thing? You don't need to decide whether the implementation uses a hash table, a tree, or a list. Any of them are valid if you keep the guarantees.

This example also shows the difference between API and vocabulary. Value, Added and AlreadyPresent are part of the messages. There are three new APIs. They would only need independent contracts if they acquired their own behavior, consumers or evolution.

Example in Go#

This implementation protects the contract and leaves the internal structure outside the API.

go
type AddResult uint8

const (
    Added AddResult = iota
    AlreadyPresent
)

type UniqueSet[T comparable] struct {
    values map[T]struct{}
}

func (s *UniqueSet[T]) Add(value T) AddResult {
    if s.values == nil {
        s.values = make(map[T]struct{})
    }
    if _, exists := s.values[value]; exists {
        return AlreadyPresent
    }
    s.values[value] = struct{}{}
    return Added
}

func (s *UniqueSet[T]) Contains(value T) bool {
    _, exists := s.values[value]
    return exists
}

func (s *UniqueSet[T]) Size() int {
    return len(s.values)
}

Public API and implementation#

A decision belongs to the API when a legitimate consumer needs to use or distinguish it and the provider is willing to keep it.

It belongs to the contractRemains in implementation
Intentions that the consumer can expressInternal call sequence
Values ​​and differences that change your behaviorIntermediate structures
Errors that you can act onTechnical errors already translated
State and observable effectsAlgorithms and coordination mechanisms
Order or quantity when they alter the external resultOptimization and distribution of work

A small surface area is useful if it allows full capacity to be expressed. Hiding a necessary difference does not reduce coupling: it forces the consumer to deduce it or look for it in the implementation.

Status and effects#

The immediate result is just a form of observation. A message can modify state that is then queried through the API or produce an effect directed to another module.

The contract must name these effects when the consumer depends on them. You should not publish the coordination used to obtain them.

In the set example, Size and Contains allow you to observe the state without exposing its representation. The consumer can check the warranty without receiving the internal collection or modifying it by reference.

When the API returns collections or mutable structures, you must decide who retains ownership. If they still belong to the provider, a copy or immutable view can prevent accidental changes. If ownership is transferred, it should be said explicitly.

Repetition and idempotence#

An operation is idempotent when repeating the same intention produces the same observable effect as executing it once. It does not mean that the provider executes a single statement or returns the same instance.

Set.Add is idempotent with respect to content: repeating the same value does not create another entry. The response can change from Added to AlreadyPresent and the guarantee will still be valid, because the final state does not change.

Idempotence only deserves to enter into the contract when there are retries, repetitions or duplicate deliveries that the consumer must be able to handle. Adding it unnecessarily introduces identity, status and retention costs.

Compatibility#

A published API accumulates consumers. Changing a name, a validity rule, an error, or the meaning of a field can break them even if the code continues to compile.

Before modifying the contract you must know:

  • what consumers exist;
  • what behavior they observe today;
  • whether the change can be additive;
  • how the two versions will coexist temporarily;
  • when the previous contract can be withdrawn.

Compatibility does not require keeping a bad decision forever. It requires treating its correction as a migration and not as an internal refactor.

Hands-on review#

Before implementing an API, check:

  1. The consumer and its intention are identified.
  2. Each message expresses a necessary capacity.
  3. Inputs, results and errors have unambiguous meaning.
  4. State and observable effects are declared.
  5. Internal details remain replaceable.
  6. Shared values ​​have clear rules and responsibility.
  7. Repetition and compatibility are decided only where they matter.

A good contract allows two things at the same time: that the consumer uses the module with confidence and that the provider changes its interior freely.

I · Foundations · Foundation 4 of 5

Foundation 4. Autonomy

Build autonomous modules, APIs and results at every system scale.

In this chapter
  1. Autonomy at each scale
  2. Complete module
  3. Valid module
  4. Autonomous results
  5. Example in Go
  6. Initial values ​​and absence
  7. Autonomous dependencies
  8. Hands-on review

Autonomy at each scale#

Autonomy does not only appear in the largest module. It can be fostered on a granular basis whenever the API-DD perspective is applied recursively.

ScaleWhat does autonomy mean
ModuleGather capacity and protect its invariants
APIOffers enough conversation without revealing how the interior is coordinated
Result messageIt has its own meaning and does not share a mutable state with the person who produced it

Autonomy does not mean isolation. A module can consume other APIs and a result can contain multiple values. The difference is that each element retains a clear limit: no consumer needs to repair its state, complete its meaning, or learn about a foreign representation.

Applying the rationale to a result does not turn it into another module. The result is still API vocabulary, but it also needs its own ownership and validity.

##Standalone API

An API is autonomous when the consumer can express an intent and understand the response within the same conversation. You do not need to open the implementation, refer to an internal structure, or coordinate collaborators who belong to the provider.

A public sequence can be part of a real protocol. You lose autonomy when your steps only exist to finish assembling the module:

text
value := Interval{}
value.SetStart(2)
value.SetEnd(8)
value.Validate()

The entire intent can be expressed in a single message:

text
Interval.New(2, 8) → Interval | InvalidInterval

Autonomy also does not require that every response include every imaginable piece of data. It includes what is necessary for the consumer to act on the differences that the contract recognizes.

Complete module#

A module is complete when it brings together what is necessary to fulfill the contract it offers. The consumer uses its capability through the API without completing internal steps, correcting its state, or deciding how its dependencies should be coordinated.

Complete does not mean self-sufficient. The module can read, calculate or produce effects through other APIs. Your responsibility is to govern those collaborations and translate them into the contract you offer.

An incompleteness signal appears when several consumers repeat the same coordination to obtain a capacity that should belong to the module. The solution is not to hide any sequence: it is to assign responsibility to the limit that can guarantee it.

Valid module#

A valid module preserves its invariants in all observable states. It can reject input or return an error; avoids continuing with an inconsistent state that another consumer has to discover later.

Validity is protected where information enters:

  • construction;
  • messages that change status;
  • interpretation of external data;
  • recovery from persistence;
  • results received from another API.

An empty initial state may be valid. It can also be invalid but safe. The API must distinguish this before producing the wrong effect.

Autonomous results#

An autonomous result belongs to the conversation that receives it. Its meaning is complete and its content does not change because the provider continues to work or because another consumer uses it.

This requires avoiding shared references to mutable state. Directly returning a slice, map, or internal pointer allows the consumer to modify the provider and the provider to alter an already delivered result. Both are no longer autonomous.

The usual alternatives are:

  • return values ​​with copy semantics;
  • use private fields and read operations;
  • copy collections when delivering and exposing their content;
  • transfer ownership explicitly;
  • offer another API to traverse data when a copy is too expensive.

Go does not have a general statement of immutability. Here, immutable means that the public API does not allow changing the result and that the provider cannot alter it after delivering it. A copied struct may contain slices, maps, or pointers that still share memory; autonomy must reach each mutable reference, not stop at the external type.

Example in Go#

Collection retains its state and returns a stand-alone Snapshot. The snapshot copies the data and only offers read operations.

go
package collection

import "errors"

var (
    ErrInvalidLimit = errors.New("invalid limit")
    ErrInvalid      = errors.New("invalid collection")
    ErrFull         = errors.New("collection full")
)

type Snapshot struct {
    values []string
}

func newSnapshot(values []string) Snapshot {
    return Snapshot{values: clone(values)}
}

func (s Snapshot) Len() int {
    return len(s.values)
}

func (s Snapshot) At(index int) (string, bool) {
    if index < 0 || index >= len(s.values) {
        return "", false
    }
    return s.values[index], true
}

type Collection struct {
    limit  int
    values []string
}

func New(limit int) (*Collection, error) {
    if limit <= 0 {
        return nil, ErrInvalidLimit
    }
    return &Collection{limit: limit}, nil
}

func (c *Collection) Add(value string) error {
    if c == nil || c.limit <= 0 {
        return ErrInvalid
    }
    if len(c.values) == c.limit {
        return ErrFull
    }
    c.values = append(c.values, value)
    return nil
}

func (c *Collection) Snapshot() Snapshot {
    if c == nil {
        return Snapshot{}
    }
    return newSnapshot(c.values)
}

func clone(values []string) []string {
    return append([]string(nil), values...)
}

New produces a valid module or an error. Add responds safely even to the zero value of Go. Snapshot does not preserve the Collection slice or expose it: Len and At allow it to be read without offering a mutation operation.

Initial values ​​and absence#

In Go, every concrete type has a value of zero. Posting New does not remove it. The contract may treat it as useful, as absent, or as invalid but safe. The Go specification defines the mechanism; the API defines its meaning.

Absent and present with a value of zero are not always equivalent either. If the difference changes the behavior, it can be represented by (T, bool), a pointer, or a named result. The transport formats are translated in the adapter so that the module receives meaning, not serialization details.

Autonomous dependencies#

A dependency is part of the construction when the module cannot fulfill its contract without it. Receiving it through an API makes what it needs visible, but does not transfer its coordination to the consumer.

It is not necessary to introduce an interface for each helper. A dependency deserves its own contract when it has other consumers or suppliers, crosses a relevant boundary or evolves autonomously. At that point the same fundamentals can be applied again.

Hands-on review#

  1. The API expresses a complete intent to its consumer.
  2. The module governs its dependencies and preserves its invariants.
  3. The creation produces a valid module or an explicit failure.
  4. Each result contains the meaning necessary to act.
  5. No results share mutable state with the provider.
  6. Slices, maps, pointers and internal elements have clear ownership.
  7. The same review is repeated at each relevant granular boundary.

Autonomy is preserved from the module to the result that crosses its API.

I · Foundations · Foundation 5 of 5

Foundation 5. Testability

Verify results, state and messages without turning the interior into a specification.

In this chapter
  1. The test as a consumer
  2. What a test can observe
  3. Example: preserve a value on failure
  4. Example in Go
  5. Speak with usual vocabulary
  6. Choose collaborators
  7. When to observe interactions
  8. What should a fake keep
  9. Review method

The test as a consumer#

A functional test represents a consumer of the module. Enter through its API and check results, status or messages that belong to the contract.

The black box does not have to cover the entire system. It can be a small module as long as the test respects its limit and does not turn internal coordination into a public promise.

text
test → API → implementation
                  └── API consumed → contributor

The main question is what should remain true for the consumer. The choice between real implementation, test double or integration comes later.

What a test can observe#

A contract offers three types of observation:

ObservationGeneric example
Direct resultReturned value or error
Status accessible by APIA subsequent query reflects the change
Outgoing messageAn event is published or an effect is requested

The helpers used, the distribution between internal objects and the chosen algorithm are left out. Also left out are the number and order of calls when they do not change the observable result.

A functional test should accept two implementations that produce the same results, the same visible state, and the same contractual messages.

Example: preserve a value on failure#

Let's consider a cache module:

text
Cache.Refresh(Key) → Value | SourceUnavailable
Cache.Get(Key) → Value | NotFound

Cache consumes another API:

text
Source.Load(Key) → Value | mistake

We want to demonstrate a single guarantee: if the source fails during an update, the old value remains available.

The test runs the actual implementation of Cache and prepares a double of Source that returns a failure. Then just look at the API:

text
given a value stored for a key
when Refresh receives SourceUnavailable
then returns SourceUnavailable
and Get retains the previous value

The test does not need to know whether the cache writes a copy first, uses a lock, or rolls back an allocation. You also don't need a real remote source, because the observed risk is the module's reaction to the failure, not the network protocol.

A different test should check the actual adapter if the risk was in serialization, configuration, or transport.

Example in Go#

Assuming the above API, the case can be written without observing any internal calls.

go
import (
    "errors"
    "testing"
)

type sourceStub struct{ err error }

func (s sourceStub) Load(string) (string, error) {
    return "", s.err
}

func TestRefreshKeepsPreviousValue(t *testing.T) {
    cache := NewCache(sourceStub{err: ErrSourceUnavailable})
    cache.Put("key", "previous")

    _, err := cache.Refresh("key")
    if !errors.Is(err, ErrSourceUnavailable) {
        t.Fatalf("expected source error, got %v", err)
    }

    got, _ := cache.Get("key")
    if got != "previous" {
        t.Fatalf("expected previous value, got %q", got)
    }
}

The test controls an indirect input through a stub and checks result and status. It does not dictate how many helpers the cache should run.

Speak with usual vocabulary#

Test double is the general term for any substitution used during a test. Within that family it is advisable to maintain the known meanings, instead of redefining fake to encompass everything.

TermUsage
Actual implementationThe same implementation used outside the test
FakeSimplified functional implementation, such as an in-memory store
StubReturns prepared responses to handle indirect input
SpyRecord messages so you can see them later
MockState and verify expectations about interactions

The terminology comes from the taxonomy collected by Gerard Meszaros and summarized by Martin Fowler in Test Double. Knowing the name helps, but the important decision remains what behavior the double replaces and what risk it leaves uncovered.

In the previous example, a stub of Source is enough: it prepares the ruling that the case needs. A functional fake would be useful if many tests needed an in-memory source with stable rules. A mock would only make sense if a specific interaction was part of the contract.

Choose collaborators#

Real implementation offers the highest fidelity and is the first choice when it is fast, deterministic, airtight, secure, and easy to build. Software Engineering at Google's guide on test doubles proposes the same pragmatic starting point.

SituationRegular choice
Fast and deterministic collaboratorActual implementation
Many cases need stable semantics without infrastructureFunctional Fake
A case needs an exceptional responselocal stub
The contract includes an outgoing messageSpy or mock
Risk depends on protocol, transaction or configurationIntegration with real adapter

It is not necessary to substitute values, entities or pure functions just because they help the case. Nor is it necessary to build real infrastructure to demonstrate a rule that does not depend on it.

When to observe interactions#

An outgoing message may form part of the contract. In that case, a spy or a mock allows you to observe its content, quantity or order.

The expectation should be limited to the functional difference:

  • content, if another consumer depends on those fields;
  • quantity, if duplicating or omitting the message changes the external effect;
  • order, if the protocol requires it.

Checking that a mapper, a specific query, or a private helper was called freezes the implementation. Verifying that a required message was posted protects the contract.

An interaction only proves that the message was attempted to be sent. It does not demonstrate that the actual recipient accepts it. When compatibility is the risk, adapter testing or integration is required.

What should a fake keep#

A functional fake implements a declared subset of the contract. Has to:

  • accept the same inputs in supported cases;
  • produce compatible results, errors and status;
  • be deterministic and isolate the state of each test;
  • visibly reject capabilities that it does not implement;
  • document the properties you omit.

You don't need to copy the infrastructure. An in-memory source can reproduce read, miss, and versions without simulating network or latency. If the case deals precisely with those omitted properties, the fake is no longer sufficient evidence.

A shared fake accumulates responsibility and deserves its own tests. When feasible, the same contract suite can be run against the fake and the real adapter to detect divergences.

Review method#

To design a test:

  1. Name the module, the API consumed and the guarantee that you want to protect.
  2. Choose a public observation: result, status or message.
  3. Remove claims about internal coordination.
  4. Run real collaborators while they are practical.
  5. Enter the smallest double that allows you to control or observe the case.
  6. State what fidelity is missing and what risk needs integration.
  7. Check that another correct implementation would also pass.

First decide what should be observed; then choose which implementations it can be demonstrated with.

II · AI-assisted development · Chapter 7 of 8

Development with AI: contracts to obtain verifiable code

Use contracts and tests to guide and verify generated code.

In this chapter
  1. AI does not eliminate design decisions
  2. Think of the system as APIs
  3. Test cases specify the result
  4. A useful distribution of work
  5. A possible flow
  6. What usually degrades the result
  7. Example in Go
  8. What an expected code improvement

AI does not eliminate design decisions#

An AI tool can scan a repository, propose an API, write tests, and deploy code quickly. That speed does not by itself resolve what behavior the system needs. If the assignment is ambiguous, the result can be technically plausible and still solve another problem.

API-DD supports this way of working because it offers a perspective to make explicit the limit of each module: who consumes it, what messages it exchanges, what it guarantees and what it keeps hidden. It does not impose an architecture, a process or a division between human and AI work. It helps turn fuzzy decisions into a contract that you can both review.

The practical difference is in the success criteria. "Generate the code for this functionality" invites you to fill in the gaps by probability. “Implement this API and demonstrate these cases” reduces ambiguity and allows you to evaluate the result by its behavior.

Think of the system as APIs#

Before generating an implementation, it is advisable to describe the affected conversations. For each module it is enough to answer what is necessary:

DecisionWhat clarifies for AI
ConsumerFrom what need the change should be designed
MessagesWhat operations, results and errors you can use
GuaranteesWhat behavior should be preserved
LimitWhat files and modules belong to the change
Internal detailsWhat decisions can the implementation freely make
APIs consumedWhat collaborations exist and which can be substituted in a test

This map reduces two common errors. The first is to extend the change with abstractions that no one asked for. The second is to copy details of the mechanism into the API: names of a library, persistence structures or internal steps that are then converted into a contract.

Not all gaps need to be filled before starting. Some are discovered when investigating the code or writing the first test. The important thing is to recognize what a pending decision is instead of letting a generated response make it accidentally.

Test cases specify the result#

A test case expresses an observable difference: an input, an action, and a result, state, or message that matters to the consumer. When executed, it also becomes feedback for the person and the AI.

A good set of cases covers the relevant differences without repeating the same rule with decorative data. For example:

text
given an empty buffer with capacity one
when a value is added
then the value becomes available

given a full buffer
when trying to add another value
then it reports that it is full and keeps the first one

These cases say more than a generic “handle errors” request. They also leave freedom to use a list, a circular array, or another representation. The test protects the contract; it does not dictate the internal route.

Seeing the test fail before implementing provides simple evidence: the test can detect the absence of the behavior. Seeing it happen later confirms that that implementation satisfies the example. Neither signal alone proves that the design is complete, but together they are more reliable than accepting code because it looks correct.

A useful distribution of work#

The distribution changes depending on the risk and the context. As a starting point:

  • people provide intention, priorities, constraints and decisions with product or architectural consequences;
  • AI can investigate existing uses, summarize contracts, propose cases, prepare a first implementation and execute verifications;
  • both review ambiguous decisions and the observable outcome.

Delegating a task does not mean delegating its acceptance criteria. The greater the impact of a decision, the more explicit it should be before turning it into code. In routine changes, tests and repository conventions can provide almost all of that context.

A possible flow#

This flow is an adaptive guide, not an API-DD condition:

  1. Research current behavior, your consumers and local conventions.
  2. Draw the affected modules and the APIs they offer or consume.
  3. Separate confirmed decisions from open questions.
  4. Make a case for each important observable difference.
  5. Run the tests and verify that the new cases fail for the expected reason.
  6. Implement the change without extending the contract unnecessarily.
  7. Run the repository checks and check the diff as an API consumer.

The cycle can go back. A test that is difficult to write may reveal an awkward API; an implementation may show that a result was missing to be represented. Correcting the contract at that time is part of the design, not a failure of the process.

What usually degrades the result#

  • Request an implementation without indicating the consumer or the expected behavior.
  • Deliver so much irrelevant context that important constraints are lost.
  • Allow AI to silently invent names, bugs or compatibility.
  • Try helpers, internal calls or data structures instead of the API.
  • Modify the test until it accepts the generated code, without checking which guarantee changed.
  • Repeat equivalent cases and confuse test volume with decision coverage.
  • Terminate the change without executing the actual project checks.

The solution is not to write a huge prompt. It is to deliver selected context: contract, cases, limits, conventions and verification commands. The appendices provide brief templates for research and agree the contract and for implement and verify it.

Example in Go#

The test describes the contract of a buffer with capacity one. The implementation is purposely not included: it could be written by a person or generated with AI and would still be evaluated by the same observations.

go
import "testing"

func TestBoundedBufferContract(t *testing.T) {
    buffer := NewBoundedBuffer[int](1)

    if got := buffer.Push(7); got != Stored {
        t.Fatalf("expected Stored, got %v", got)
    }
    if got := buffer.Push(8); got != Full {
        t.Fatalf("expected Full, got %v", got)
    }

    value, ok := buffer.Pop()
    if !ok || value != 7 {
        t.Fatalf("expected first value, got %v, %v", value, ok)
    }
}

The case establishes capacity, response and conservation of the first value. It does not set auxiliary classes, number of calls or internal structure. That freedom allows the AI ​​to propose an implementation and the team to change it later without altering the contract.

What an expected code improvement#

Thinking about APIs and test cases narrows the solution space without choosing the mechanism in advance. AI is given meaningful names, concrete boundaries, and executable examples; the team receives an objective way to review the result.

The benefit is not that all generated code is correct. It is no longer evaluated only by its appearance: it must respect the contract, exceed the agreed cases and preserve the internal freedom of the module.

AI speeds up a proposal; the contract and the tests let us decide whether it works.

III · TDD, DDD and Hexagonal · Chapter 8 of 8

API-DD together with TDD, DDD and hexagonal architecture

Combine approaches through the questions they answer.

In this chapter
  1. Different questions
  2. DDD brings meaning
  3. Hexagonal architecture guides conversations
  4. TDD guides construction
  5. Example in Go
  6. Frequent confusions
  7. Is every API a port?
  8. Does every module need a language interface?
  9. Does API-DD prescribe a design order?
  10. Does a test with several modules stop being unitary?
  11. Does watching an outgoing message break the black box?
  12. A practical sequence

Different questions#

API-DD does not replace these approaches. They can be combined when each retains its main question.

FocusQuestion that helps answer
DDDWhat does the model mean and within what context?
Hexagonal architectureWhat conversations connect the application with actors and technologies?
TDDHow do we grow behavior through actionable feedback?
API-DDHow do the modules communicate and what contract does each one offer?

It is not necessary to adopt all four. The table serves to prevent a technique from answering questions that do not correspond to it.

DDD brings meaning#

DDD helps discover concepts, invariants, language, and model boundaries. When two areas use a similar word, it allows you to decide if they share meaning or need different representations.

API-DD can leverage that output to craft the conversations consumers need. It does not alone decide which model is correct nor does it require that work begin with DDD.

The reference used here is DDD Reference by Eric Evans.

Hexagonal architecture guides conversations#

A port represents a purposeful conversation; Adapters connect specific mechanisms to it. From API-DD, that port can be looked at as an API: messages, vocabulary, guarantees and effects.

Not every module needs to be converted into a port. Two internal modules can collaborate using a local API without representing an architectural boundary of the application. Converting each relationship to a port would add visibility and substitution without a real need.

The original intent of ports and adapters is described by Alistair Cockburn in Hexagonal Architecture.

TDD guides construction#

TDD provides the feedback loop: choose the next behavior, write a test that fails, implement it and refactor. API-DD helps formulate that behavior as an observable guarantee of a module.

text
contract guarantee → red → green → refactor

The test may discover that the contract was incomplete. In that case the decision is reviewed before continuing; the implementation is not forced to preserve a wrong specification.

The cycle is supported by the description of Martin Fowler's TDD, based on the work of Kent Beck.

Example in Go#

Suppose a module needs to save and retrieve bytes per key. This API can act as an exit port when interchangeable providers exist.

go
type Key string
type Value []byte

type Store interface {
    Load(Key) (Value, bool, error)
    Save(Key, Value) error
}

The hexagonal architecture orients this dependency towards the consumer's need. API-DD helps make visible decisions such as what absence means, who owns the returned bytes, and what errors should be distinguished. TDD allows those guarantees to be implemented one by one. If there will never be another provider or relevant boundary, a separate interface may be unnecessary.

Frequent confusions#

Is every API a port?#

No. Every port offers an API, but an API can also exist between internal modules that do not cross the application boundary.

Does every module need a language interface?#

No. An API can be expressed using a specific type, functions, methods, or messages. A technical interface provides when a consumer needs replacement or decoupling, not as a ceremonial requirement.

Does API-DD prescribe a design order?#

No. You can start with any module, rule or conversation whose contract and risk are clear. API-DD offers a perspective to review each module as an API, without prescribing a temporal direction to discover the system.

Does a test with several modules stop being unitary?#

The number of objects or modules does not determine what risk the test covers. It is more useful to declare the observed API and which implementations are involved than to discuss a universal label.

Does watching an outgoing message break the black box?#

Not when that message is part of the promised effect. Yes when there is internal collaboration that another correct implementation could resolve differently.

A practical sequence#

  1. Use available modeling to clarify concepts and boundaries.
  2. Identify the modules involved and the conversations between them.
  3. Design each module as an API.
  4. Formulate observable guarantees and choose the appropriate level of proof.
  5. Deploy in small cycles and refactor without altering the contract.

The actual work will not be linear. A name discovered during a test can change the model; an architectural restriction may force you to revise the API. The separation of questions serves to understand the decision, not to impose rigid phases.

DDD clarifies meaning, architecture guides relationships, TDD guides change, and API-DD helps design the conversation.

IV · Operational guide · Appendix A of 2

Appendix A: discover the contract

A short prompt to investigate a change and leave functional evidence in red.

In this chapter
  1. Purpose
  2. Prompt to copy
  3. Expected result

Purpose#

This prompt turns a task into a small contract and executable evidence of the missing behavior. Investigate first, ask only about decisions that change the outcome, and don't implement production capacity yet.

It works best when attaching the repository, task, and any context that cannot be retrieved from the code.

Prompt to copy#

Replace the fields in square brackets.

text
Act as a software analyst using API-DD. Investigate the change, define the contract of the affected modules and leave tests failing because the behavior is still absent. Do not implement the production capability yet.

Repository:
[REPOSITORY]

Task:
[TASK]

Additional context:
[CONTEXT]

Work rules:

- A module is an encapsulated part of software that others use through an API.
- The API is the module protocol: accepted messages, vocabulary, guarantees and consumed APIs.
- Operations, inputs, results, errors and events are part of the contract; they are not independent APIs unless they have their own consumers and behavior.
- Review the five fundamentals at each relevant boundary: recursion, vocabulary, visibility, autonomy and testability.
- Do not convert helpers, algorithms or internal coordination into a contract.
- Do not invent functional decisions. If an ambiguity changes results, errors, status, effects, compatibility, or security, ask the minimum question before continuing.
- Respect the instructions, conventions, automation and generated code of the repository.

1. Research

- Read instructions, documentation, automation, code and related tests.
- Execute a limited baseline and separate pre-existing failures.
- Identify the affected modules, their consumers, their providers and the APIs they offer or consume.
- Record what behavior already exists and which part the task requests.
- Do not ask for verifiable information in the repository.

2. Define the contract

For each affected module, describe compactly:

- consumer and intention;
- messages and visibility;
- inputs, results and distinguishable errors;
- state and observable effects;
- invariants, repetition and compatibility;
- internal decisions that must remain replaceable.

Formulate each guarantee as:

Given [state], when [message], then [result, state, or observable effect].

Keep two separate cases only if they detect different failures. Do not automatically generate variants for null, empty, zero or negative: include them when they represent a real difference to the contract.

3. Choose how to demonstrate each guarantee

- Use the actual implementation when it is fast, deterministic, airtight and secure.
- Use a fake for simplified functional semantics, a stub to prepare responses and a spy or mock for contractual outgoing messages.
- Use integration when the risk depends on serialization, persistence, transactions, configuration or actual protocol.
- Do not add infrastructure or E2E outside the authorized scope; leave the proposal and explain the pending risk.

4. Leave evidence in red

- Write the smallest test that demonstrates each new guarantee.
- Enter through the API that your consumer would use.
- Add only the signatures or composition essential to run the test.
- Each test must compile or load, reach its observation and fail due to the absent behavior.
- Don't fabricate the bug with a panic, skip, a padding error, or a deliberately false assertion.
- If a double with its own semantics is missing, leave the specified case as pending; don't make up those semantics during this phase.

5. Delivery

Include:

- summary of the change;
- table of modules, consumers and APIs;
- deduplicated catalog of guarantees;
- reused existing tests and new tests;
- commands executed and exact reason for each failure;
- pending decisions, doubles or necessary integrations;
- modified files, risks and out of scope for deployment.

Do not declare the phase complete if a pending decision materially changes the contract or if no test has yet reached the expected functional failure.

Expected result#

The handover should allow someone else to implement the change without rediscovering the task or guessing functional decisions. The evidence in red demonstrates which guarantee is missing; it does not prescribe how to solve it.

IV · Operational guide · Appendix B of 2

Appendix B: implement the contract

A short prompt to implement and verify the agreed contract.

In this chapter
  1. Purpose
  2. Prompt to copy
  3. Expected result

Purpose#

This prompt recovers the contract from the previous phase, reproduces its failures and takes each guarantee to green without adapting the tests to the chosen internal route.

It can be used in the same conversation or with a handover that includes task, decisions, tests and reproducible commands.

Prompt to copy#

text
Act as an implementer using API-DD. Recover the contract defined in the previous phase, reproduce its failures and implement each guarantee until the change is verified.

Work rules:

- Preserve the agreed messages, results, errors, state and effects.
- Do not change an assertion to accommodate the implementation.
- Do not export code, introduce an interface or extend an API just to facilitate a test.
- Keep algorithms and internal coordination free while the contract is fulfilled.
- Do not extend the task with layers, options, retries, caching, concurrency or validations that no guarantee needs.
- If an absent functional decision or contradiction appears, investigate the evidence and ask questions before choosing a new rule.

1. Recover the transfer

- Read the conversation or dossier, the work tree and the new tests.
- Identify modules, consumers, APIs, guarantees and out-of-scope items.
- Reproduce each expected failure before modifying production.
- Execute a limited baseline and separate pre-existing failures.
- If a test already passes, determine if the behavior existed, the case is incorrect, or the repository changed.

2. Respect the project

- Read applicable generated code instructions, automation, conventions, and notices.
- Keep names, organization, error handling, and tools consistent with the modified area.
- Translate HTTP representations, messages or persistence concerns in their adapters; do not propagate them for convenience.

3. Review the five fundamentals

For each affected module check:

- recursion: what APIs it offers or consumes and what collaborations deserve another module;
- vocabulary: whether names and messages retain their meaning from the consumer;
- visibility: what should be public and what details remain inside;
- autonomy: whether the module is complete, valid and delivers results without shared mutable state;
- testability: whether the guarantees can be observed by the API.

When a module is decomposed into others with their own contracts, the same review applies. Don't convert every function, parameter or error into another module.

4. Solve test support

- Keep actual implementations that are practical.
- Reuse existing compatible doubles before creating new ones.
- A shared fake implements a declared subset of the contract and needs its own tests.
- A local stub can prepare a response; a spy or mock observes only messages whose content, quantity or order is contractual.
- Use integration only for authorized risks that depend on real technology.

5. Implement guarantee by guarantee

For each case:

1. select the smallest red test;
2. implement the minimum general rule that satisfies it;
3. run the test and its related group;
4. refactor without changing the API;
5. check that another equivalent implementation could also pass.

If a legacy test is attached inside, first identify what warranty it protected before replacing it.

6. Check and deliver

- Apply format and generation according to the project.
- Run specific tests, reasonable regression scope, relevant lint and static analysis.
- Review the diff to remove temporary support, accidental exports and side changes.
- Report implemented behavior, modified modules and APIs, relevant decisions, commands and results.
- Declare pre-existing failures, risks, out of scope and unexecuted verifications.

Do not declare success if you concealed a deviation by changing the test, if a new guarantee never showed its failure, or if a functional decision remains unresolved.

Expected result#

The implementation is complete when all confirmed guarantees are green, the rest of the relevant regression is preserved, and the design still allows changing the interior of each module without breaking its consumers.