🤖 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.
}
Certainly not this:
void registerCar(Optional<Car> car) {
if (car.isEmpty()) {
return;
}
// Register the car.
}
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.
}
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)
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
)
Soon enough, the implementation begins to split:
if (trackingNumber.isPresent()) {
// Ship with tracking.
} else {
// Ship without tracking.
}
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)
and:
void shipTrackedOrder(
Order order,
ShippingAddress address,
TrackingNumber trackingNumber
)
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);
where customer might be null, prefer something like this:
Customer customer = repository.find(id)
.orElseThrow(() -> new CustomerNotFoundException(id));
sendWelcomeEmail(customer);
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)
);
}
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, andOptional<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.Unmarshalerwith a method likefunc (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
}
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)
Then let the function describe the operation itself:
func CloseAccount(account Account) (Account, error) {
return account.Close()
}
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
}
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();
}
Account account = repository.find(id);
closeAccount(account);
After:
void closeAccount(Account account) {
account.close();
}
Account account = repository.find(id)
.orElseThrow(() -> new AccountNotFoundException(id));
closeAccount(account);
Java with Optional
Before:
void closeAccount(Optional<Account> account) {
account.ifPresent(Account::close);
}
closeAccount(repository.find(id));
After:
void closeAccount(Account account) {
account.close();
}
Account account = repository.find(id)
.orElseThrow(() -> new AccountNotFoundException(id));
closeAccount(account);
Go
Before:
func RegisterCar(car *Car) error {
if car == nil {
return nil
}
return registry.Register(car)
}
car, _ := repository.FindCar(ctx, carID)
return RegisterCar(car)
After:
func RegisterCar(car Car) error {
return registry.Register(car)
}
car, err := repository.FindCar(ctx, carID)
if err != nil {
return err
}
return RegisterCar(car)
C\
Before:
void CloseAccount(Account? account)
{
if (account is null)
{
return;
}
account.Close();
}
CloseAccount(account);
After:
void CloseAccount(Account account)
{
account.Close();
}
Account account = repository.Find(id)
?? throw new AccountNotFoundException(id);
CloseAccount(account);
Kotlin
Before:
fun closeAccount(account: Account?) {
account?.close()
}
closeAccount(repository.find(id))
After:
fun closeAccount(account: Account) {
account.close()
}
val account = repository.find(id)
?: throw AccountNotFoundException(id)
closeAccount(account)
Swift
Before:
func closeAccount(_ account: Account?) {
account?.close()
}
closeAccount(repository.find(id))
After:
func closeAccount(_ account: Account) {
account.close()
}
guard let account = repository.find(id) else {
throw AccountNotFoundError(id)
}
closeAccount(account)
Rust
Before:
fn close_account(account: Option<&mut Account>) {
if let Some(account) = account {
account.close();
}
}
close_account(repository.find_mut(id));
After:
fn close_account(account: &mut Account) {
account.close();
}
let account = repository
.find_mut(id)
.ok_or(AccountNotFoundError::new(id))?;
close_account(account);
TypeScript
Before:
function closeAccount(account?: Account): void {
if (!account) {
return;
}
account.close();
}
closeAccount(await repository.find(id));
After:
function closeAccount(account: Account): void {
account.close();
}
const account = await repository.find(id);
if (!account) {
throw new AccountNotFoundError(id);
}
closeAccount(account);
Python
Before:
def close_account(account: Account | None) -> None:
if account is None:
return
account.close()
close_account(repository.find(account_id))
After:
def close_account(account: Account) -> None:
account.close()
account = repository.find(account_id)
if account is None:
raise AccountNotFoundError(account_id)
close_account(account)
Ruby
Before:
def close_account(account)
return if account.nil?
account.close
end
close_account(repository.find(id))
After:
def close_account(account)
account.close
end
account = repository.find(id)
raise AccountNotFoundError, id unless account
close_account(account)
Further Reading
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
- Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship
- Martin Fowler, Ubiquitous Language
- Martin Fowler, Anemic Domain Model
- Martin Fowler, Tell, Don't Ask
- Martin Fowler, Refactoring
- Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software
- Bertrand Meyer and Design by Contract
- Richard Feldman, Making Impossible States Impossible
- Single Responsibility Principle
Top comments (2)
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...