Skip to content

Software architecture · part 3 of 6

Programming to interfaces

How to write code that depends on contracts, not implementations, with examples in Go, Java, Kotlin and TypeScript, and when an interface is not worth it.

intermediateRevised on · 26 min read

Font size:
Column width:
Tone:
Line spacing:
Typeface:

Picture a phone charger. It does not ask whether the power in the wall came from a hydroelectric dam or a diesel generator. The outlet offers a shape and a voltage, and any device that fits works. That agreed shape is what we call an interface: a contract about what can be asked for, without saying how the request is fulfilled inside.

In programming, an interface is a set of method signatures, without the code that runs them. The type that writes that code is an implementation. Programming to interfaces is the habit of making the code that uses something depend on its contract, not on one specific implementation. When an order service receives a Notificador (a notifier), it does not matter whether the message goes out by email, by SMS or into a list kept in memory inside a test.

A note on names: the code and the diagrams in this lesson keep the Portuguese identifiers of the original version. Notificador means Notifier, Notificar means Notify, Pedido means Order, Repositorio means Repository, Salvar means Save, Listar means List and Confirmar means Confirm. The code stays as it was run, so the quoted outputs stay real.

This lesson starts with the problem this habit solves, defines an interface carefully and shows how each language decides that a type satisfies a contract. The code examples are in Go, Java, Kotlin and TypeScript. Then the lesson opens up what Go keeps inside an interface, connects the idea with dependency inversion and with hexagonal architecture, shows how to test with test doubles, and ends with the part almost nobody teaches: the cases where creating an interface is a mistake.

Lesson map

  1. The problem: code that knows too much.
  2. What an interface is and why it exists.
  3. The same idea in four languages, with three ways to decide satisfaction.
  4. What Go keeps inside and the traps that come from it.
  5. The dependency inversion that interfaces make possible.
  6. Tests with test doubles, compared with integration tests.
  7. When not to create an interface.
  8. Common mistakes and good practices.
  9. Exercises.
  10. Glossary.

1. The problem: code that knows too much

Imagine a service that confirms orders. The rule is simple: save the order and notify the customer. In the first version, whoever wrote the code took the shortest path, which is calling the database and the mail server directly from inside the method. It works, and that is why nobody complains at first.

package antes

import (
	"database/sql"
	"net/smtp"
)

type Servico struct {
	db *sql.DB
}

func (s *Servico) Confirmar(id, cliente string) error {
	if _, err := s.db.Exec("INSERT INTO pedidos (id, cliente) VALUES ($1, $2)", id, cliente); err != nil {
		return err
	}
	return smtp.SendMail("smtp.exemplo.com:587", nil, "loja@exemplo.com", []string{cliente}, []byte("Pedido confirmado"))
}

The problem shows up in three places. The first is testing. To check that the rule “confirm saves and notifies” is right, you need a running database and an SMTP server that accepts the message. A test that should take milliseconds starts depending on infrastructure, and any slowness in the test database becomes a failure. The second is change. If the company decides to send SMS instead of email, the service changes entirely, not only the part that sends the message. The third is reading. Whoever opens the file finds SQL and SMTP in the middle of the business rule, and has to separate on their own what is a decision from what is a technical detail.

The figure shows what the test needs in order to run. The arrow from Confirmar() leaves the test and reaches the service, which is exactly what we want to verify. From there on, the service talks to the database and to SMTP.

The rule test calls the order service, which talks straight to PostgreSQL and SMTP; with the database down, the call is blocked before the rule runs
figure 1 · Order service coupled straight to the database and SMTP

When the database is down, the INSERT arrow is blocked by the pink X, and the test fails before it reaches the rule it wanted to check. The failure says nothing about the order. It only shows that the test depended on a machine that was not running.

2. What an interface is and why it exists

An interface declares which methods exist and with which signatures, but does not say how they work. It holds no state. The official Kotlin documentation states this clearly: interfaces can declare abstract methods and also methods with an implementation, but they cannot hold state, and their properties must be abstract or have accessors. The Java documentation describes an interface as a reference type that contains method signatures, default methods, static methods and nested types.

Think of a TV remote control. It has volume, channel and power buttons. Whoever presses a button does not need to know whether the TV is a tube set or an LED set, and the TV does not need to know whether the remote is plastic or an app on a phone. The buttons are the contract. Whoever makes each part chooses how the button behaves inside.

In code, the contract of the example is this interface in Go:

type Notificador interface {
	Notificar(destino, mensagem string) error
}

Any type with a method Notificar that has this signature serves as a notifier. The word implements appears nowhere. Section 3 shows why this works in Go and how other languages solve the same problem.

You already use interfaces without noticing. Go’s io.Reader is an interface with a single method, Read(p []byte) (n int, err error). Files, network connections and in-memory buffers all satisfy this same interface, so a function that copies data works with all of them. In Java, List is an interface: the variable is declared as List<String>, and the object behind it can be an ArrayList or a LinkedList, with different costs for each operation. In Kotlin, Iterable plays the same role for the for loop.

This is polymorphism, which means many forms. The same call aviso.Notificar(...) runs different behavior depending on the object behind it. The diagram below shows the structure that makes this useful.

Email, SMS and a test spy sit behind one Notificador contract; the order service depends only on that contract, and each implementation points to it
figure 2 · One Notificador contract with three implementations

The ConfirmacaoPedido, in orange, is the caller. The Notificador, in purple, is the contract. The solid arrow labeled notificar() goes from the caller to the contract, and the packages show the call passing through it. The dotted arrows go from each implementation to the contract, which represents “implements”. Each implementation points to the contract, and the contract knows none of them. The NotificadorEspiao is green because it exists for the test, and section 6 explains how it comes in.

3. The same idea in four languages

Before showing the code, it helps to understand the question each language answers in a different way: how does a type join a contract? There are three answers in this lesson.

The first is explicit declaration, or nominal typing. Java and Kotlin work this way: the class states, in its own header, that it implements the interface. The second is structural typing, used by TypeScript. The TypeScript documentation says that type compatibility is based only on members, in contrast with nominal typing. An object fits an interface if it has the requested members, even without declaring anything. The third is Go’s implicit satisfaction. The official Effective Go documentation sums up the idea: a type implements an interface just by implementing its methods, with no explicit declaration. Go checks this at compile time by comparing the type’s method set with the interface’s.

The same contract written in the four languages looks like this. The snippets are excerpts of the examples that compiled in this review.

In Java, the word implements appears in the class:

interface Notificador {
    void notificar(String destino, String mensagem);
}

class NotificadorEmail implements Notificador {
    @Override
    public void notificar(String destino, String mensagem) {
        System.out.println("e-mail para " + destino + ": " + mensagem);
    }
}

In Kotlin, the colon after the class name plays the same role:

interface Notificador {
    fun notificar(destino: String, mensagem: String)
}

class NotificadorEmail : Notificador {
    override fun notificar(destino: String, mensagem: String) {
        println("e-mail para $destino: $mensagem")
    }
}

In TypeScript, the class says nothing about the interface. The assignment is what checks the shape:

export interface Notificador {
  notificar(destino: string, mensagem: string): Promise<void>;
}

export class NotificadorEmail {
  async notificar(destino: string, mensagem: string): Promise<void> {
    console.log(`e-mail para ${destino}: ${mensagem}`);
  }
}

const aviso: Notificador = new NotificadorEmail();

In Go, the struct does not mention the interface, and the check happens at the assignment:

type Notificador interface {
    Notificar(destino, mensagem string) error
}

type NotificadorEmail struct{}

func (n NotificadorEmail) Notificar(destino, mensagem string) error {
    fmt.Println("e-mail para", destino+":", mensagem)
    return nil
}

var _ Notificador = NotificadorEmail{} // compile-time check

The last line of the Go example is a useful idiom. It creates nothing at runtime. It only makes the compiler confirm that the type satisfies the interface. If the method signature changes, the error appears right there, and not in the distant place where the type is used.

The figure compares the four forms side by side, with the same interface and the same implementation.

The same Notificador interface in Java, Kotlin, TypeScript and Go; explicit declarations drawn as thick lines and implicit ones as dotted lines
figure 3 · Same contract in Java, Kotlin, TypeScript and Go

The two thick lines, from Java and Kotlin, say that the relationship was declared. The dotted ones, from TypeScript and Go, say that the shape decided. This difference does not change what the code does in production, but it changes where the error appears when you get something wrong.

LanguageHow the type joins the contractWhen the error appears
Javaimplements in the class headerAt compile time, in the class itself
Kotlin: Notificador in the class headerAt compile time, in the class itself
TypeScriptCompatible shape of the membersAt assignment or argument passing
GoCompatible method setAt assignment or argument passing

Error messages also differ, and it helps to recognize them. If you forget a method in Go, the compiler points out the missing one. If you misspell a method name in an object literal in TypeScript, the compiler replies: Object literal may only specify known properties, and 'enviar' does not exist in type 'Notificador'. In Go, using a value where the method requires a pointer produces the message shown in section 4: lista does not implement adicionador (method Adicionar has pointer receiver).

4. What Go keeps inside an interface

In Go, a variable of interface type holds more than the value. It holds a pair: the concrete type of the value and the value itself. The field names change between versions, but the idea is stable. In the runtime code of the toolchain installed for this review (Go 1.27.2, file runtime/runtime2.go), an interface with methods is represented by a struct with two pointers, tab and data. The tab points to a table that describes the concrete type and the methods of the interface, and in the runtime that table is called itab. An empty interface, such as any, uses another struct, with the type and the data. You do not need to memorize these names. What matters is that the type travels with the value, and this detail causes one of the most famous Go errors.

The Go specification says that the value of an uninitialized interface variable is nil. Two interface values are equal when they have identical dynamic types and equal dynamic values, or when both are nil. Notice the word “type”. An interface is only nil when its type is also nil.

Here is the case that catches developers off guard:

package main

import "fmt"

type Erro struct{ msg string }

func (e *Erro) Error() string { return e.msg }

// operacao devolve um *Erro como error. Quando não há falha, a variável é um
// ponteiro nil, mas o valor de retorno não é um nil de interface.
func operacao(falha bool) error {
	var e *Erro
	if falha {
		e = &Erro{msg: "falhou"}
	}
	return e
}

func main() {
	err := operacao(false)
	fmt.Println("err == nil?", err == nil)
	fmt.Printf("tipo: %T, valor: %v\n", err, err)
}

The output of this program is err == nil? false, followed by tipo: *main.Erro, valor: <nil>. The function did not return any error, but the caller’s if err != nil enters the handling block. The pointer is nil, but the interface is not, because it carries the type *Erro. The figure separates the two cases.

In Go, an interface holding nil with an empty type gives err == nil true, but a nil pointer inside an interface has a filled type and gives false
figure 4 · A Go interface is nil only when its type is also nil

In the top case, the literal return nil leaves the type empty, and the comparison with nil is true. In the bottom case, the variable e has type *Erro and value nil. When the function returns e as an error, the type is copied into the interface, and the comparison is false. The fix is to return a literal nil when there is no error, and never a typed pointer variable disguised as an error.

Another rule that comes up often has to do with methods. In Go, a method declared with a pointer receiver, func (l *Lista) Adicionar, belongs to the method set of *Lista, not to that of Lista. The specification defines that the method set of a pointer to a type T includes the methods with receiver *T and with receiver T. The method set of the value T includes only the methods with receiver T. A value satisfies the interface only if its method set contains all the methods requested.

The figure compares the two cases with the same type Arquivo.

In Go, the Arquivo type with a pointer method does not satisfy the Leitor interface, while *Arquivo does; the difference lies in each one's method set
figure 5 · In Go, only the pointer *Arquivo satisfies the Leitor interface

The value Arquivo, on top, has an empty method set, because Ler was declared with receiver *Arquivo. That is why the satisfaction arrow is blocked. The pointer *Arquivo, below, has Ler in its method set and satisfies Leitor. People who write code often pass Arquivo{} by mistake, and then the compiler returns the message shown in section 3. The fix is to pass &Arquivo{}. The official Go guidance on choosing a receiver is direct: when in doubt, use a pointer receiver.

5. The inversion that interfaces make possible

The dependency inversion principle says that high-level modules should not depend on low-level modules, and that both should depend on abstractions. The interface is the abstraction that makes this rule workable. Without it, the order service would have to import the database package in order to save. With it, the service imports only its own package, which defines the contract, and the database adapter imports the service’s package in order to implement the contract.

Notice that there are two directions, and they do not coincide. At runtime, the call goes from the core to the adapter, because the adapter is the one that actually sends the email. But in the source code, the dependency arrow points to the port. The figure shows both together.

The notificar() call goes from the core to the adapter at runtime, while the code arrows point to the port, which the core uses and the adapter implements
figure 6 · Runtime call and code dependency point in opposite directions

The thick solid arrow, from notificar(), is the call at runtime. The dotted ones are the code dependencies, and both point to the purple hexagon. This is the effect of the inversion: the port sits in the center, and the core and the adapter both depend on it, not on each other. The composition root, which is usually the main function, is in green. It creates the adapter and hands it to the service through the constructor.

In Go, the real adapter lives in a separate package and declares, in its own file, that it satisfies the port:

package postgres

import (
	"database/sql"

	"exemplo.com/poi/pedido"
)

// PedidoPostgres é um adaptador: traduz a porta Repositorio para SQL.
type PedidoPostgres struct {
	db *sql.DB
}

func NovoPedidoPostgres(db *sql.DB) *PedidoPostgres {
	return &PedidoPostgres{db: db}
}

func (r *PedidoPostgres) Salvar(p pedido.Pedido) error {
	_, err := r.db.Exec("INSERT INTO pedidos (id, cliente) VALUES ($1, $2)", p.ID, p.Cliente)
	return err
}

// Verificação em tempo de compilação: se o método mudar de assinatura, o build quebra aqui.
var _ pedido.Repositorio = (*PedidoPostgres)(nil)

The pedido package defines the port and the rule. The postgres package depends on it, and never the other way around. If someone tries to import postgres inside pedido, Go refuses to compile because of the import cycle, so the compiler itself guards the direction of the rule.

This same rule, applied to the whole system and not to a single type, is what hexagonal architecture calls ports and adapters. The hexagonal lesson covers the general shape. This lesson stays at the level of a single type, which is where the decision happens day to day.

6. Tests with test doubles

A test double is any object that stands in for a real dependency during a test. The generic name comes from stunt doubles in movies. Three kinds show up often. A fake is a working but simplified implementation, such as a repository kept in a map. A stub returns fixed answers. A mock verifies that certain calls happened. In Go, the most common practice is to write the double by hand, as a plain struct, without generation frameworks. The note on mocks and test doubles, in Portuguese, goes deeper into the comparison with other languages.

The two doubles below stand in for the database and the notifier:

// repoMemoria é um dublê: guarda em memória e pode simular falha.
type repoMemoria struct {
	salvos []pedido.Pedido
	falha  error
}

func (r *repoMemoria) Salvar(p pedido.Pedido) error {
	if r.falha != nil {
		return r.falha
	}
	r.salvos = append(r.salvos, p)
	return nil
}

// avisoEspiao registra as mensagens enviadas, sem enviar nada.
type avisoEspiao struct {
	mensagens []string
}

func (a *avisoEspiao) Notificar(destino, mensagem string) error {
	a.mensagens = append(a.mensagens, destino+": "+mensagem)
	return nil
}

And this is the test that checks the rule:

func TestConfirmarSalvaEAvisa(t *testing.T) {
	repo := &repoMemoria{}
	aviso := &avisoEspiao{}
	s := pedido.NovoServico(repo, aviso)

	if err := s.Confirmar(pedido.Pedido{ID: "p1", Cliente: "ana@exemplo.com"}); err != nil {
		t.Fatalf("erro inesperado: %v", err)
	}
	if len(repo.salvos) != 1 {
		t.Fatalf("esperava 1 pedido salvo, veio %d", len(repo.salvos))
	}
	if len(aviso.mensagens) != 1 {
		t.Fatalf("esperava 1 aviso, veio %d", len(aviso.mensagens))
	}
}

Run with go test -v, this test passed without a database, without SMTP and without network. A second test, which configures repoMemoria to fail, checks the bad path: when the order is not saved, the notice must not go out. Both passed in this review.

The figure shows the path of the test.

A unit test calls the service, which saves to an in-memory double; the PostgreSQL adapter is dashed, outside the test, tied to the same port
figure 7 · Unit test with a fake: the path skips the infrastructure

The test, in pink, calls the service, in orange. The service saves to the RepoMemoria, in green, and the round-trip arrow shows that the result returns to the test. The PedidoPostgres is dashed on purpose: it exists, implements the same port, but does not take part in this path.

A test with a fake does not replace an integration test. It checks the service’s rule, but it does not confirm that the SQL is correct, that the column exists or that the transaction works. For that there is the test against a real database, as described in the note on integration tests with Testcontainers (in Portuguese). The two are complementary: the fake gives almost instant feedback about the rule, and the integration test covers the edge.

7. When not to create an interface

This is the section that most materials on the subject lack, and perhaps the most important one. An interface is a tool with a cost: each contract is one more file to open, one more name to understand and one more layer between the code and what it does. When the contract buys nothing in return, it only gets in the way.

The figure gathers the questions that usually decide.

Decision flow for an interface: two real implementers, a boundary with a database, network or clock, or a test that must swap the dependency
figure 8 · When to create an interface: three questions

The first question is whether there are two or more real implementers. If there are, the contract keeps the code from spreading through the differences between them. The second is whether the code touches a boundary with something external, such as a database, network, file or the system clock. That boundary changes over time and is often what you want to swap or simulate. The third is whether any test needs to replace the dependency with a double. If all three answers are no, the best decision is usually to keep the concrete type, such as a function or a plain struct.

There are some cases where the answer is almost always no. The first is an interface with a single implementer and no boundary. It has no reason to exist, and its name only duplicates the type’s name. The second is data structs, such as DTOs and value objects, which carry fields and not behavior to swap. The third is pure functions, such as formatting a date or calculating a tax, which have no side effects to hide. The fourth case is the leaky abstraction. An interface named Executar(sql string) does not hide the database: it only wraps it, and whoever uses it still writes SQL. The fifth is the fat interface, which forces every double to implement methods that the consumer never uses. Section 8 covers it in detail.

In Java, a habit that appears often in older projects is creating IUsuarioService and UsuarioServiceImpl for a single service. The I prefix and the Impl suffix add no information. If there is only one implementation and no test needs to swap it, the pair is ceremony.

In Go, the guidance is more specific. The CodeReviewComments documentation says that interfaces generally belong to the package that uses the values of the type, not to the package that implements them. It also says not to define interfaces before they are used, because without a real usage example it is hard to see whether the interface is needed. And it advises against defining interfaces on the implementer’s side just to make mocking easier, and instead designing the API so it can be tested through the real implementation’s public interface. Many people summarize this as “accept interfaces, return structs”. The summary is useful, but the official source is more precise: the package that implements should return concrete types, usually pointers or structs.

8. Common mistakes and good practices

Almost everyone gets interfaces wrong for the same reason: they create them too early. The thinking goes that, since interfaces are good, creating one is always an investment. In practice, an interface created before its second use becomes a form nobody fills in, and each new file requires someone to maintain it. The rule of thumb is to wait for the second real reason to exist (a second implementer, a boundary or a test that swaps the dependency) before declaring the contract.

Fat interface

The Go proverbs, gathered by Rob Pike in his Gopherfest SV 2015 talk, put this in one sentence: “The bigger the interface, the weaker the abstraction.” An interface with five methods forces every double to write all five, even when the test needs only one. It is also harder for new implementations to satisfy, because each extra method is another reason to reject the implementation. The fix is to split the contract by what each consumer uses, as the figure below shows.

A report that only lists orders must write five methods in a double when the port is fat, and only one when the port has a single method
figure 9 · A fat interface forces every test double to implement everything

On the left, the report uses a single method, but the port has five. The double is forced to implement all five. On the right, the port has only Listar(), and the double writes that method and nothing else. In Go, the standard library follows this rule with single-method interfaces, such as io.Reader and io.Writer. By convention, a single-method interface is named after the method with the -er suffix, as Effective Go notes.

Empty interface as an escape hatch

any (or interface{}) accepts any value, which makes it tempting when typing becomes awkward. The same Rob Pike proverb has another line for this: “interface{} says nothing.” If a function takes any, the compiler stops helping, and the error starts appearing at runtime. Use the concrete type or a small, named interface instead.

Typed nil

The case in section 4 is the most common error with interfaces in Go. The practical rule is that a function returning error must return a literal nil when there is no error, and never a pointer variable that may be nil. It is also worth avoiding returning concrete types from a function that promises an interface, because the caller loses the chance to notice the problem.

Forgotten pointer receiver

When a method changes the state of the value, the receiver should be a pointer. The satisfaction error appears when the type has methods with pointer receivers and the code passes the value where the interface is expected. The fix is to pass &Tipo{}. When there is no state change and the type is small, a value receiver is also valid, and the official guidance treats this as a question of efficiency.

Mock that tests the implementation

A less visible mistake is writing doubles that check the exact order of internal calls. When the test verifies the path and not the result, any refactoring that changes the internal sequence breaks the test, even without changing the behavior. Prefer doubles that store the result, such as the repoMemoria from section 6, and check what was saved and what was announced.

Good practices in one place

Declare the interface in the package of the consumer, give it the smallest number of methods that does the job, and let the implementation return the concrete type. When possible, make the compiler check satisfaction with a line such as var _ Contrato = (*Tipo)(nil). Every interface that does not point to a second real use deserves a review before it is kept.

9. Exercises

  1. Take a function that sends a receipt by email and calls SMTP directly. Create the port, the real implementation, a double that stores the messages in memory and a test that confirms the receipt was sent to the right recipient. The test cannot open any network connection.
  2. Write a function that returns *Erro as an error and that, when there is no failure, returns a nil pointer. Show the if err != nil firing with no real error. Then fix the function without changing its public signature.
  3. Declare a type Contador with the method Incrementar using a pointer receiver. Try to assign Contador{} to an interface that has Incrementar, read the compiler message and fix it in two different ways. Say which of the two makes more sense and why.
  4. An interface Repositorio has five methods, but a report only uses Listar(). Redesign the contract so that the report depends on a single-method interface, and explain what changes in the test double.
  5. For each case, say whether you would create an interface and why: (a) a pure function that calculates freight from weight and distance; (b) the client of a payment gateway’s API; (c) a DTO with the fields of a form; (d) the system clock, used to expire sessions.

Suggested answers. In exercise 1, the double lives in the test package, and the test checks only the recipient and the content stored. In exercise 2, the fix is to replace return e with an explicit return nil when there is no error. In exercise 3, the first fix is to pass &Contador{}, and the second is to change the receiver to a value, which only makes sense if the method does not change the state. The first is usually the right choice. In exercise 4, the report starts depending on ListaPedidos with one method, and the double writes only Listar(). In exercise 5, (a) does not need an interface, because it is a pure function with no boundary; (b) calls for an interface, because it is an external boundary and the test needs to swap it; (c) does not need one, because it is only data; (d) calls for an interface, because time is a boundary that the test wants to control.

10. Glossary

Interface: a set of method signatures without the implementation. In Go, a type satisfies the interface without declaring it.

Implementation: a type that writes the code of an interface’s methods.

Contract: the agreement between the caller and the implementer about what the methods receive and return.

Polymorphism: the same call runs different behavior depending on the object behind it.

Method set: the list of methods a type has available. In Go, the method set of T and of *T can be different.

Nominal typing: a type satisfies an interface only when it declares that explicitly, as in Java and Kotlin.

Structural typing: a type satisfies an interface when it has the requested members, without any declaration, as in TypeScript.

Test double: an object that replaces a real dependency in a test. It can be a fake, a stub or a mock.

Typed nil: a nil pointer stored in an interface, which is not nil because its type remains filled in.

Dependency injection: handing the implementation to the caller through the constructor or a parameter, instead of letting the code create it inside. See dependency injection.

Composition root: the place, usually main, that knows the concrete implementations and wires them together.

Coupling: the degree to which one part of the code depends on the details of another. Interfaces help reduce it, when the contract buys something in return.

Sources

Consulted on 2026-11-02:

The code examples were run in this review: Go 1.27.2 (go vet, go test -v and go run), JDK 21.0.8 (javac and java), Kotlin 2.1.21 (kotlinc from IntelliJ), TypeScript 6.0.3 (tsc in strict mode) and Node 24.21.0 for the version without types. The outputs quoted in the text, such as err == nil? false and the compiler error messages, are the real ones from those runs.

Not verified in this review: the distinction between fake, stub and mock, which follows common usage in the area and not a page consulted now.

↑ Back to top

Keyboard shortcuts

Open search
/
Toggle light/dark theme
t
Previous lesson in series
←
Next lesson in series
→
Shortcuts list
?