Capability's costume, standardised
A capability-first architecture, as stated here and here, is feasible when all services in an estate communicate uniformly across transports. This essay argues for standardising this transport-specific costume. A good analogy is the shipping container: it standardised the transport mechanism instead of the goods being exchanged.
So this essay proposes a standardised payload to invoke capabilities and one costume per transport. Once the costume is standardised, it can no longer pretend to be a capability. No estate that I know of works this way. I’ve used MUST, MUST NOT, SHOULD and MAY as prescribed in RFC 2119.
Two services, two clocks#
I’ll use the same example quoted in previous essays. A bank’s reporting context has statement_service with generate_statement as the exposed capability. A billing_service invokes this capability to generate statements for the customer.
Each service has its own clock. Independent teams deploy them, and they have unrelated release frequencies. I propose a central store, accessible by both services, to maintain the capability list. The central store maintains the status quo between services, as they never deploy together. It offers the following operations.
- Capability registration and decommissioning
- Capability listing across the estate
- Dependency registration and decommissioning
A deployment marks the boundary where a service’s capabilities can drift apart. Therefore, for every service, the deployment MUST inform the central store of the capabilities it intends to export. This is capability registration. The other boundary is decommissioning a service. Sadly, most estates lack a dedicated pipeline. Every service decommissioning MUST have a pipeline that informs the central store of the capabilities that it intends to decommission. This is capability decommissioning.
A capability listing equips a stranger with knowledge of what capabilities to use and how. The central store MUST provide the capabilities that the current estate can serve, free of stale entries.
Without knowing a capability’s dependencies, the owners of the service exposing it can never apply a breaking change with confidence. The store MUST maintain a list of dependencies on the capabilities per transport so that it is easier to manage. To make this a reality, each service deployment MUST register its dependencies on capabilities that are exposed across the estate. This is dependency registration. Every service decommissioning MUST also have a pipeline that informs the central store of the dependencies it intends to decommission. This is dependency decommissioning.
Here is my vision of what the above example would look like. The statement_service’s build generates an entry for each capability that it intends to expose, assuming it is feasible. Its deployment then registers this capability list with the central store. While building the billing_service, a human or an agent can find capabilities by searching the store by intent. Once the required capability is found, a client is generated for the capability deterministically, also assuming it is feasible. Its deployment then registers its dependency on generate_statement with the central store. As long as the dependency exists, statement_service cannot drift from its promise. With the promise upheld, billing_service can call statement_service’s capabilities directly. The store is never in the path of invocation.
The entry#
The capability entry is what the callee registers and read by a human or an agent building the caller. From here on, this essay builds the entry one property at a time. To start, a name lets the caller ask for something.
Name: what a capability is called within its service. It MUST NOT contain a dot.
A name means little unless someone answers for it. The bounded context the service belongs to is the owner. The team behind the context is who the caller should go to if the capability misbehaves.
Owner: The most specific bounded context that performs the capability. It MUST NOT contain a dot.
To prevent name collisions, you need notation that points to a unique capability. This is its full name. This is a vocabulary for the readers and is not part of the entry.
Full name: The owner and the name joined by a dot. It uniquely identifies the capability in the estate.
A failure tells the caller what happened and lets it plan appropriately. The boundary adds its own default set of failures, which it doesn’t explicitly declare.
Failures: The closed set of failures that belong to the capability. Each is a name and carries no data. The five boundary failures are implied for every capability and MUST NOT be listed. The closed set MUST include only declared failures and MUST NOT include boundary failures.
Boundary failures: The following are boundary failures that apply to any transport.
no_reply- when nothing comes back by the caller’s deadline.
unavailable- when the other side cannot answer.
rejected- when the receiver cannot accept what the caller sent.
unauthenticated- when the credential is missing or cannot be verified.
access_denied- when the caller is known and is not allowed.
Every agent or human that reads the capabilities must know what purpose it serves. This enables them to choose between capabilities. This is the intent and MUST be written by a human.
Intent: One line stating what the capability is for and when to use it. The build MUST NOT generate it, and the central store MUST refuse an entry without one.
The caller also needs to know the transport mechanism in which the capability is exposed. The deployment must generate this information and feed it to the capability registration step. Individual capability endpoints are not listed; instead, the deployment provides only the address to reach them.
Transports: The transports that serve the capability, each with the address where it answers, and for messaging, the queue. The kind MUST be written at build time. The addresses MUST be added at deploy, and MUST NOT carry a context.
[
{ "kind": "rest", "address": "https://statement-service.bank.internal" },
{ "kind": "grpc", "address": "statement-service.bank.internal:443" },
{ "kind": "messaging", "address": "amqps://broker.bank.internal", "queue": "statement_service.requests" }
]
So far, we have everything for the caller to find a capability and reach it. To invoke it, the caller needs the following.
Input: The JSON Schema of what the capability takes.
Output: The JSON Schema of what the capability returns when it succeeds.
You need two more properties once an estate has more than a handful of services.
Visibility: Who may invoke the capability. internal allows only services in the owner’s context. external allows any service in the organisation. Every entry MUST declare one. This is enforced at build time and should not be confused with runtime authorisation.
Finally, the version. Every capability exposed on a transport gets a version. Here, generate_statement on REST gets its own version, and on gRPC it gets another. The central store assigns the version, not the service. This is decided during capability registration.
Version: Two numbers, major and minor, for each transport on which the capability is served. A breaking change moves the major, an additive change moves the minor, and a change no caller can see moves neither. The central store MUST assign it and MUST publish it only when the deployment takes all the traffic.
The invocation#
The invocation has 2 layers. The payload is what the entry defines. This payload is wrapped in an envelope specific to the transport that exposes it. Every property of the envelope is defined below.
Payload: The inputs on the way in and on the way back; the outcome, which is the output or a problem, naming one declared failure. It is JSON on every transport, and it MUST be the same on every transport.
Manifest: The capability being invoked and the identity of this invocation. The costume carries it in its own envelope, and the reply’s envelope carries the identity back where the transport does not pair a reply with its request.
Identity: An opaque string chosen by the caller, unique among its invocations of the capability, and the same on every retry.
Credential: What the estate’s identity provider issues to a caller to prove which service it is. The envelope carries it, and the receiver establishes the caller from it before anything else.
The identity is the de-duplication key. This is an enabler, and the callee performs the de-duplication logic on its end if needed, or else re-runs the function.
A failure is represented as a problem.
Problem: A JSON object with 3 members. type that defines the failure as a URN, and a caller MUST handle it. title and detail give more context and MUST NOT be parsed.
{
"type": "urn:capability:generate_statement:account_closed",
"title": "Account closed",
"detail": "The account was closed on 31 August 2026."
}
The costumes#
This is where the standardisation plays an important role. Every transport in the estate MUST adhere to one costume. This holds only when every caller and receiver across the estate follows it.
A service must have one receiver per transport and not one per capability. First, a receiver receives the envelope. It then validates the caller by presenting the credentials to the estate’s identity provider. Then the capability is resolved and invoked. It then wraps the outcome in a reply envelope.
An invocation MUST complete on the transport that it started on. The receiver ends on a plain function with the capability’s signature. The person or team who owns the capability only writes this function and nothing else.
REST#
In REST, the manifest is just an address. A simple route template lets you access every capability the service offers.
PUT /capabilities/{name}/executions/{id}
{name} - name of the capability
{id} - is the capability’s identity.
The Authorisation header carries the credentials. The request body is the payload, and the response is the outcome.
PUT is chosen here on purpose. It is an idempotent method as defined in RFC 9110. A retry here MUST invoke the same capability without any side effects beyond the first attempt. The callee MUST uphold it. Either the service performs the function with no side effects, or it deduplicates the identity and returns a previously computed result.
A 200 response signals successful execution, and the caller attempts to parse the outcome from the response body. A 409 signals a declared failure, and the response is parsed as a problem. Similarly, other status codes can be translated to the boundary failures listed above. Declared failures MUST NOT become status codes.
gRPC#
Every service here exposes one method, Invoke. The request message carries the manifest (name and identity for the invocation) along with the inputs as JSON. The credential is part of the metadata or the channel. The reply message is the outcome as JSON. Like REST, failures are represented as the problem defined above.
Statuses are translated to boundary failures. It MUST NOT be translated to declared failures. An OK status signals the caller to look for the outcome in the response. An FAILED_PRECONDITION status signals the caller to look for declared failures. Like REST other boundary failures can be derived from the status.
Messaging#
Messaging is unique because the service triggering the message needs to know which queue to send it to. For this reason, the entry for messaging transport MUST contain the broker and queue information.
Here, the envelope MUST contain the manifest including the full name of the capability, identity, reply-to queue and credential.
The caller MUST own its reply queue. The payload is the same as in other transports. The reply can contain both the output and the failures. Since there is no status code, the caller must check for the existence of failure first before attempting to parse the response as the outcome. The caller MUST persist what it needs to continue when the reply arrives, as any instance can process the reply.
A queue delivers at least once. The callee must de-duplicate based on the identity if needed. A message that expects no reply is not a capability invocation.
Left to the estate#
This proposal standardised three things: the entry, payload and the costume per transport. Everything else, like identity broker, service discovery, tracing, monitoring, etc., continues to use the estate’s own infrastructure. The central store is one additional component this proposal adds to the estate. How the store can be built is still open.
The limits#
How the build generates the entries it registers from code is its own essay.
Messaging can carry a reply that can arrive after a few days. This cannot keep the caller waiting the whole time. However, if the caller chooses to persist its state prior to the invocation, it can spring into action when the reply arrives. This is durable execution, and it deserves its own essay.
A message can also outlive the deployment and reach a caller that no longer has the capability it expects. This translates to the boundary failure unavailable, and the central store cannot prevent it.
A stream is not a capability. A subscription to the stream can be one. What flows is not a capability invocation.
This proposal scopes to services running in an estate and doesn’t cater for clients on end-user devices.
Nine fields, two layers, one costume#
Nine fields per entry, two layers of costume and one costume per transport. Every service holds a standard mechanism to invoke its capabilities and the same rules to parse the responses. A central store that maintains the status quo for the promises between services and is never in the path of the invocation.
The costume is declared in the entry. It is simply not a capability, and now there is a place that says so.