API-DD

Practical guide

The name that forces you to open the implementation

Comparing OrderService.Process with Checkout.Place shows how technical names hide the conversation and how to find more stable vocabulary.

By Lautaro Mei

This API could manage payments, import files or generate reports:

go
type OrderService interface {
    Process(context.Context, OrderRequest) (OrderResponse, error)
}

We only know that a service exists, that it processes something and that it returns a response. To use it you have to open OrderRequest, read Process and find out what each error means.

The name does not reduce complexity. It moves it into the implementation.

Read the call as a sentence#

The consumer wants to convert a cart into an order:

go
response, err := orderService.Process(ctx, OrderRequest{
    CartID: cartID,
})

We can distribute the meaning across module, message, and result:

go
orderID, err := checkout.Place(ctx, cartID)

The second call reads as a phrase: "checkout, place the order for this cart." OrderID explains what the consumer gets. Errors such as ErrEmptyCart or ErrPaymentRejected name situations that you can take action on.

The longer call contains no more meaning. It only contains more technical categories.

Bad names are often true for too many things#

Process, Execute, Handle, Data, Item, and Response are not always incorrect. They are weak when they could name almost any system operation.

text
OrderService.Process(OrderRequest) → OrderResponse

describes the form of the code: service, request, process and response.

text
Checkout.Place(CartID) → OrderID | EmptyCart | PaymentRejected

describes the conversation: context, intent, input, and observable differences.

The consumer does not need to know if a workflow, a transaction or several handlers are executed inside.

A good name survives another implementation#

Suppose Process today calls a payment provider and saves to SQL. Naming it ProcessAndPersistOrder seems more accurate:

go
ProcessAndPersistOrder(ctx, request)

But that precision describes the mechanism. If an event is published tomorrow and persistence works differently, the name is no longer true even though the consumer's need has not changed.

Place still works. It describes the intent both implementations preserve.

A good check is to imagine two valid interiors. If the name only describes one, it probably belongs within the module.

Context allows short names#

PlaceOrder can be useful as an isolated function. Within checkout, repeating CheckoutOrderService.PlaceOrder adds noise:

go
checkout.Place(cartID)

The package already provides the context. The method provides the action. The argument provides the object.

Names are not evaluated separately. Get may be ambiguous in a generic package and sufficient in a small collection. Place can mean many things alone, but it is concrete within checkout and alongside CartID.

The result also needs a name#

OrderResponse forces fields to be inspected:

go
type OrderResponse struct {
    Success bool
    ID      string
    Message string
}

It allows dubious combinations: Success == false with a non-empty ID, or Success == true without an ID. The name describes a response container, not its meaning.

The conversation can use specific values ​​and errors:

go
type OrderID string

var (
    ErrEmptyCart       = errors.New("empty cart")
    ErrPaymentRejected = errors.New("payment rejected")
)

func (c *Checkout) Place(
    ctx context.Context,
    cartID CartID,
) (OrderID, error)

The new type contributes because it avoids confusing an order with a cart and names the result. There is no need to create OrderIDValueObject or PlaceOrderResponseDTO in the main API.

Renaming may reveal a boundary issue#

If it is difficult to find a verb, perhaps the module brings together several intentions:

go
OrderService.Process(request)

could create, cancel, export, or retry based on an Action field. No synonym for Process fixes that mix. Separating messages makes the capability visible:

go
checkout.Place(cartID)
orders.Cancel(orderID)
orders.Export(query)

The problem was not a lack of creativity. It was too big a conversation.

The tests also speak better#

Compare these names:

go
func TestProcessReturnsFalse(t *testing.T)
func TestPlaceRejectsAnEmptyCart(t *testing.T)

The second declares intention, condition and result. It allows you to imagine the incorrect alternative without opening the body of the test.

Precise vocabulary improves code, documentation, metrics, and team conversations because they all describe the same promise.

A good name allows you to understand the conversation; a bad name forces it to be rebuilt from the mechanism.