DEV Community

Cover image for A Function Signature Is Part of Your Domain Model
Alessandro Aiezza
Alessandro Aiezza

Posted on

A Function Signature Is Part of Your Domain Model

🤖 AI Summary

Short on time? Ask AI to summarize this article.

Copy the prompt below into ChatGPT, Claude, Gemini, or your preferred AI assistant.

Please summarize this article for me. Focus on:

- The main argument
- Practical takeaways
- Engineering tradeoffs discussed
- Situations where the advice may or may not apply

Article: https://dev.to/aaiezza/function-signatures-are-part-of-your-domain-model

Software models reality. Your function signatures should, too.

One of the most valuable lessons I have learned throughout my career is this:

A function signature is part of your domain model.

It is not merely a compiler requirement. It is not only an API definition. It is a statement about what must already be true before an operation can meaningfully occur.

This has become one of my favorite code smell tests whenever I am designing or reviewing code:

Does the code I am writing make sense in reality?

More often than not, that question exposes the design problem before a single line of implementation is written.

Software Models Reality

Whether you are building a banking platform, a hospital management system, an e-commerce application, a logistics platform, or a game, your software exists to model some aspect of reality.

Types represent nouns.

Methods represent verbs.

Objects represent things.

Functions represent actions.

Every time we introduce another class, method, function, interface, endpoint, or data structure, we are making a statement about the domain we are trying to model.

If the software does not make sense in reality, it is worth asking whether it truly makes sense in code.

A Function Signature Is a Contract with Reality

Consider these operations:

  • Register a car.
  • Close a bank account.
  • Ship an order.
  • Pay an invoice.
  • Schedule a doctor's appointment.

Now ask yourself:

How many of these operations make sense if the thing being operated on does not actually exist?

Imagine walking into the DMV and saying:

I would like to register... maybe a car.

Or calling your bank:

I would like to close an account... if I happen to have one.

The conversation would stop immediately.

The operation itself has preconditions.

Reality does.

Our software should too.

A function signature communicates those preconditions.

If a function requires a Car, then requiring a Car in the signature is part of the contract.

Not this:

void registerCar(Car car) {
    if (car == null) {
        return;
    }

    // Register the car.
}
Enter fullscreen mode Exit fullscreen mode

Certainly not this:

void registerCar(Optional<Car> car) {
    if (car.isEmpty()) {
        return;
    }

    // Register the car.
}
Enter fullscreen mode Exit fullscreen mode

And while Go does not have null for value types, the same smell often appears through pointer parameters:

func RegisterCar(car *Car) {
    if car == nil {
        return
    }

    // Register the car.
}
Enter fullscreen mode Exit fullscreen mode

In all three examples, the function is being asked to answer a question that should have been answered before the function was called:

Do we actually have a car to register?

That decision belongs at the call site.

The domain operation should begin only after its preconditions have been established.

Shift the Conversation

One of the most useful mindset shifts in API design is moving away from defensive programming and toward domain modeling.

Instead of asking:

Can this function handle null?

Ask:

Does this operation even make sense if the thing does not exist?

Those are very different questions.

The first question asks how to make the implementation more defensive.

The second question asks whether the API faithfully represents the business domain.

That second question is usually the more valuable one.

Defensive programming has its place. System boundaries are messy. User input is unreliable. Databases may not contain the record you expected. HTTP requests may omit fields. Third-party APIs may lie.

But those are boundary concerns.

At those boundaries, absence needs to be represented, validated, and translated.

Once your application has crossed into its domain model, the code should speak in terms of valid domain concepts whenever possible.

Nullable Parameters Usually Indicate Multiple Responsibilities

Imagine this method:

void shipOrder(Order order, ShippingAddress address)
Enter fullscreen mode Exit fullscreen mode

The signature describes one operation:

Ship this order to this address.

Now imagine someone changes it to this:

void shipOrder(
    Order order,
    ShippingAddress address,
    Optional<TrackingNumber> trackingNumber
)
Enter fullscreen mode Exit fullscreen mode

Soon enough, the implementation begins to split:

if (trackingNumber.isPresent()) {
    // Ship with tracking.
} else {
    // Ship without tracking.
}
Enter fullscreen mode Exit fullscreen mode

The function is no longer modeling one operation.

It is modeling two.

Maybe the domain actually has two different commands:

void shipOrder(Order order, ShippingAddress address)
Enter fullscreen mode Exit fullscreen mode

and:

void shipTrackedOrder(
    Order order,
    ShippingAddress address,
    TrackingNumber trackingNumber
)
Enter fullscreen mode Exit fullscreen mode

Or maybe tracking belongs somewhere else entirely.

The Optional parameter was not the root problem. It merely revealed that the API had not fully modeled the domain yet.

The Caller Should Establish Preconditions

A function should be able to assume its contract has already been satisfied.

Instead of this:

Customer customer = repository.find(id);

sendWelcomeEmail(customer);
Enter fullscreen mode Exit fullscreen mode

where customer might be null, prefer something like this:

Customer customer = repository.find(id)
    .orElseThrow(() -> new CustomerNotFoundException(id));

sendWelcomeEmail(customer);
Enter fullscreen mode Exit fullscreen mode

The caller answers the question:

Do I actually have a customer?

Only then does it invoke the operation.

The operation itself remains focused on exactly one responsibility:

void sendWelcomeEmail(Customer customer) {
    emailService.send(
        customer.emailAddress(),
        "Welcome!",
        welcomeTemplateFor(customer)
    );
}
Enter fullscreen mode Exit fullscreen mode

sendWelcomeEmail does not need to know how customers are found. It does not need to decide what happens when a customer is missing. It does not need to return silently when the domain operation is impossible.

It sends a welcome email to a customer.

That is the whole job.

But What About Optional?

This article is not an argument against Optional.

In fact, I like Optional as a return type.

Returning an Optional<Customer> communicates something useful:

There may or may not be a customer.

That is valuable information. It forces the caller to acknowledge absence instead of pretending absence cannot happen.

Accepting an Optional<Customer> as a parameter communicates something very different:

This operation may or may not have a customer.

That should cause us to pause.

If the customer is optional, does the operation itself make sense?

Or are we really modeling multiple operations?

Those are the questions worth asking.

As a general rule:

  • Optional<T> as a return type can clarify absence.
  • Optional<T> as a parameter often imports ambiguity into the function.
  • T?, nil, null, and Optional<T> parameters deserve the same design scrutiny.

The syntax changes by language. The smell is usually the same.

Go Pointer Parameters Deserve Extra Scrutiny

In Go, this shows up a little differently.

A pointer parameter can communicate several things:

  • This value may be mutated.
  • This value is large enough that copying is undesirable.
  • This value has identity.
  • This value may be absent.

That is a lot of meaning for one *.

The first meaning deserves special attention.

Most of the time, we should not want mutability by default.

Mutable APIs are harder to reason about because the caller has to understand not only what value was passed, but who else might observe that value after the function changes it. Immutability gives us smaller mental models. A function receives a value, produces a result, and does not quietly alter something the caller still holds.

So in Go, a pointer parameter deserves two questions:

  • Does this operation make sense if the value is missing?
  • Does this operation actually need to mutate the value I passed in?

Sometimes mutation is unavoidable because the technology stack requires it. A common Go example is implementing json.Unmarshaler with a method like func (t *Type) UnmarshalJSON(data []byte) error. That method needs a pointer receiver because unmarshaling fills the existing value.

I would still treat that as a boundary compromise, not a default design pattern. Go does not give us the same type construction protections that some other languages do, and as engineers, we often inherit the stack we have rather than the one we would design from scratch. That reality is worth tolerating. It is not worth generalizing into every domain API.

Consider:

func CloseAccount(account *Account) error {
    if account == nil {
        return nil
    }

    account.Close()
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The nil check makes the operation ambiguous.

Does a nil account mean the account was not found?

Does it mean the caller made a programming error?

Does it mean closing a missing account is considered successful?

Does it mean the function should do nothing?

Those are domain questions. They should be answered explicitly before calling CloseAccount.

If closing an account can be modeled as producing a new account state, prefer a value-based API:

account, err := repository.FindAccount(ctx, accountID)
if err != nil {
    return err
}

closedAccount, err := CloseAccount(account)
if err != nil {
    return err
}

return repository.SaveAccount(ctx, closedAccount)
Enter fullscreen mode Exit fullscreen mode

Then let the function describe the operation itself:

func CloseAccount(account Account) (Account, error) {
    return account.Close()
}
Enter fullscreen mode Exit fullscreen mode

No pointer.

No nil.

No hidden mutation.

Just a valid account going in and a valid account state coming out.

If Account must be passed by pointer because identity or in-place mutation truly matters, make that convention explicit in your codebase:

func CloseAccount(account *Account) error {
    account.Close()
    return nil
}
Enter fullscreen mode Exit fullscreen mode

In that version, *Account still should not mean "maybe an account."

It means "the account whose state will be changed."

That distinction matters.

The problem is not the pointer by itself. The problem is allowing the pointer to carry multiple meanings that the function never clearly names.

Simpler APIs Produce Simpler Implementations

When every required parameter is actually required, useful things begin to happen.

Functions become shorter.

Conditional branches disappear.

Tests become easier to write.

Names become clearer.

Responsibilities separate naturally.

Most importantly, the API begins to resemble the language domain experts would actually use when describing the business.

That is rarely an accident.

This is one of the core ideas behind Domain-Driven Design: software should model the language and rules of the business. Eric Evans calls that shared language between developers and domain experts the ubiquitous language.

If the business would never say "register a car that might not exist," why should our API?

Reality Has Preconditions

Reality does not let us:

  • Register cars we do not own.
  • Close bank accounts that do not exist.
  • Ship orders that were never placed.
  • Pay invoices that were never issued.
  • Schedule appointments for patients who are not in the system.

Neither should our APIs.

A well-designed function signature communicates those rules before a single line of implementation executes.

This is also why the idea fits so naturally with Design by Contract. A caller has obligations. A callee has obligations. If a function requires an Account, then the caller's job is to provide an Account.

Not maybe an account.

Not an account-shaped absence.

An account.

A Practical Heuristic

The next time you are designing a function, method, endpoint, command, or service API, ask yourself:

  • Does this operation make sense if one of these parameters does not exist?
  • Am I modeling one operation or several?
  • Should this decision be made before the function is called?
  • Am I modeling the business, or merely defending against invalid inputs?
  • Would a domain expert naturally describe the operation this way?
  • Does this parameter communicate a domain requirement, or does it leak uncertainty from an earlier step?

If any answer feels uncomfortable, your function signature may be trying to tell you something.

Listen to it.

Rule of Thumb

If a missing value changes whether the operation should happen at all, do not make it a nullable parameter. Make the caller decide whether the operation should be invoked.

Your function signature is more than an implementation detail.

It is part of your domain model.

Design it accordingly.

Appendix: Before and After

The language changes, but the design principle remains the same.

Java

Before:

void closeAccount(Account account) {
    if (account == null) {
        return;
    }

    account.close();
}
Enter fullscreen mode Exit fullscreen mode
Account account = repository.find(id);
closeAccount(account);
Enter fullscreen mode Exit fullscreen mode

After:

void closeAccount(Account account) {
    account.close();
}
Enter fullscreen mode Exit fullscreen mode
Account account = repository.find(id)
    .orElseThrow(() -> new AccountNotFoundException(id));

closeAccount(account);
Enter fullscreen mode Exit fullscreen mode

Java with Optional

Before:

void closeAccount(Optional<Account> account) {
    account.ifPresent(Account::close);
}
Enter fullscreen mode Exit fullscreen mode
closeAccount(repository.find(id));
Enter fullscreen mode Exit fullscreen mode

After:

void closeAccount(Account account) {
    account.close();
}
Enter fullscreen mode Exit fullscreen mode
Account account = repository.find(id)
    .orElseThrow(() -> new AccountNotFoundException(id));

closeAccount(account);
Enter fullscreen mode Exit fullscreen mode

Go

Before:

func RegisterCar(car *Car) error {
    if car == nil {
        return nil
    }

    return registry.Register(car)
}
Enter fullscreen mode Exit fullscreen mode
car, _ := repository.FindCar(ctx, carID)
return RegisterCar(car)
Enter fullscreen mode Exit fullscreen mode

After:

func RegisterCar(car Car) error {
    return registry.Register(car)
}
Enter fullscreen mode Exit fullscreen mode
car, err := repository.FindCar(ctx, carID)
if err != nil {
    return err
}

return RegisterCar(car)
Enter fullscreen mode Exit fullscreen mode

C\

Before:

void CloseAccount(Account? account)
{
    if (account is null)
    {
        return;
    }

    account.Close();
}
Enter fullscreen mode Exit fullscreen mode
CloseAccount(account);
Enter fullscreen mode Exit fullscreen mode

After:

void CloseAccount(Account account)
{
    account.Close();
}
Enter fullscreen mode Exit fullscreen mode
Account account = repository.Find(id)
    ?? throw new AccountNotFoundException(id);

CloseAccount(account);
Enter fullscreen mode Exit fullscreen mode

Kotlin

Before:

fun closeAccount(account: Account?) {
    account?.close()
}
Enter fullscreen mode Exit fullscreen mode
closeAccount(repository.find(id))
Enter fullscreen mode Exit fullscreen mode

After:

fun closeAccount(account: Account) {
    account.close()
}
Enter fullscreen mode Exit fullscreen mode
val account = repository.find(id)
    ?: throw AccountNotFoundException(id)

closeAccount(account)
Enter fullscreen mode Exit fullscreen mode

Swift

Before:

func closeAccount(_ account: Account?) {
    account?.close()
}
Enter fullscreen mode Exit fullscreen mode
closeAccount(repository.find(id))
Enter fullscreen mode Exit fullscreen mode

After:

func closeAccount(_ account: Account) {
    account.close()
}
Enter fullscreen mode Exit fullscreen mode
guard let account = repository.find(id) else {
    throw AccountNotFoundError(id)
}

closeAccount(account)
Enter fullscreen mode Exit fullscreen mode

Rust

Before:

fn close_account(account: Option<&mut Account>) {
    if let Some(account) = account {
        account.close();
    }
}
Enter fullscreen mode Exit fullscreen mode
close_account(repository.find_mut(id));
Enter fullscreen mode Exit fullscreen mode

After:

fn close_account(account: &mut Account) {
    account.close();
}
Enter fullscreen mode Exit fullscreen mode
let account = repository
    .find_mut(id)
    .ok_or(AccountNotFoundError::new(id))?;

close_account(account);
Enter fullscreen mode Exit fullscreen mode

TypeScript

Before:

function closeAccount(account?: Account): void {
  if (!account) {
    return;
  }

  account.close();
}
Enter fullscreen mode Exit fullscreen mode
closeAccount(await repository.find(id));
Enter fullscreen mode Exit fullscreen mode

After:

function closeAccount(account: Account): void {
  account.close();
}
Enter fullscreen mode Exit fullscreen mode
const account = await repository.find(id);

if (!account) {
  throw new AccountNotFoundError(id);
}

closeAccount(account);
Enter fullscreen mode Exit fullscreen mode

Python

Before:

def close_account(account: Account | None) -> None:
    if account is None:
        return

    account.close()
Enter fullscreen mode Exit fullscreen mode
close_account(repository.find(account_id))
Enter fullscreen mode Exit fullscreen mode

After:

def close_account(account: Account) -> None:
    account.close()
Enter fullscreen mode Exit fullscreen mode
account = repository.find(account_id)

if account is None:
    raise AccountNotFoundError(account_id)

close_account(account)
Enter fullscreen mode Exit fullscreen mode

Ruby

Before:

def close_account(account)
  return if account.nil?

  account.close
end
Enter fullscreen mode Exit fullscreen mode
close_account(repository.find(id))
Enter fullscreen mode Exit fullscreen mode

After:

def close_account(account)
  account.close
end
Enter fullscreen mode Exit fullscreen mode
account = repository.find(id)
raise AccountNotFoundError, id unless account

close_account(account)
Enter fullscreen mode Exit fullscreen mode

Further Reading

Top comments (2)

Collapse
 
algorhymer profile image
algorhymer

That Further Reading list...

Fowler is a Chief Scientist... at a consulting company.
He never showed code that he shipped (Did he do anything besides collecting tall tales from his consultant colleagues?!).
Has 0 repos.
Even O(N^2)-coder Uncle Bob has at least some code.
Why should I believe Fowler? He's like HR.
I mean decent person, you can talk to him in the kitchenette about dogs and weather etc.,
but - oh boy - we don't let HR near the code base, am I right?

That Further Reading list...

pls-dont-make-me-read-it