Skip to content

Software architecture · part 2 of 6

Dependency inversion in practice

The rule that makes business code depend on contracts, not on the database, HTTP or a library. Examples in Go, Java, Kotlin and TypeScript, with diagrams.

intermediateRevised on · 30 min read

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

Imagine the refrigerator in your kitchen. It is not welded to the hydroelectric plant. It has a plug, the wall has a socket, and between the two there is an agreed format. The refrigerator does not need to know which plant generated the power or which lines it traveled through. It only requires the format. If the utility changes its source, the refrigerator keeps working, and if you buy a new refrigerator, the plant never finds out.

Notice the detail that gives the principle its name. The format was defined by the side that consumes the power, and the plant is the one that adapts to it. Nobody has to rebuild the wall of the house to buy a new refrigerator. Whoever depends sets the contract, and whoever supplies adjusts to that contract.

The Dependency Inversion Principle (DIP) applies this same idea to software. It says that the most important part of the system, the business rule, must not depend on technical details such as a database, an HTTP library or an email provider. The business rule declares the contract it needs, and the technical details come to obey that contract. The name sounds like it requires mathematics, but the idea fits in one sentence: the core of the system writes the rules it needs, and the technology fits into them.

This lesson starts from the problem the principle solves. It then separates the control arrow from the dependency arrow, which is where most people stumble, shows the original statement, brings examples in Go, Java, Kotlin and TypeScript, explains how dependency injection and the composition root support the principle in practice, and ends with tests, common mistakes, limits and exercises.

lesson map

  1. The problem: business rules tied to the detail.
  2. Control arrow and dependency arrow.
  3. The original statement and the meaning of each term.
  4. Examples in Go, Java, Kotlin and TypeScript.
  5. How it works inside: the interface on the consumer’s side, injection and the composition root.
  6. Test doubles without touching the core.
  7. The detail that leaks through the port.
  8. Inversion of control, injection and what people often confuse.
  9. Common mistakes and good practices.
  10. When not to use it.
  11. Exercises.
  12. Glossary.

1. The problem: business rules tied to the detail

A use case is an action of the business, such as registering an order. In many projects, this action starts out tied to the database, because that is the shortest path in the first week. The code below shows the pattern. The type Servico holds a concrete repository from the postgres package, and the postgres package needs the type Pedido, which lives in the pedido package.

// pacote pedido/servico.go
package pedido

import "example.com/loja/postgres"

type Pedido struct {
	ID    string
	Valor int
}

// O caso de uso conhece o adaptador concreto.
type Servico struct {
	repo *postgres.Repositorio
}

func (s *Servico) Registrar(p Pedido) error {
	return s.repo.Salvar(p)
}
// pacote postgres/repositorio.go
package postgres

import "example.com/loja/pedido"

type Repositorio struct{}

// O adaptador precisa do tipo Pedido, que está no pacote do caso de uso.
func (r *Repositorio) Salvar(p pedido.Pedido) error {
	return nil
}

Running go run . on this project, the compiler answers with import cycle not allowed, because pedido imports postgres, which imports pedido. In Go, this cycle is a compile error, so the language pushes whoever writes the code toward a cleaner package structure. Java and TypeScript accept cycles between packages without complaint, so the same design compiles, and the damage shows up later, as code that nobody can move without breaking other modules.

The cycle is only the symptom. The real problem is that the use case became dependent on how the data is stored. Three consequences appear early. Testing Registrar requires a running database, because the concrete type accepts nothing else. Changing the database access library changes the signature of the use case, and with it all the code that calls it. And the business rule, which is the most valuable part of the system, cannot be reused in another context without taking the database along.

This diagnosis is not new. Robert C. Martin published the principle in 1996, in a column of the C++ Report magazine, with an example that still holds up. He imagines a program that copies characters from the keyboard to the printer. When the need arises to copy to a disk as well, the copy module gains one more if, then another, and ends up depending on every device. In the words of the article, the module becomes rigid and fragile. The copy rule, which is the only interesting part of the program, cannot be reused because it is tied to the devices it controls.

There is also the propagation of changes, which hurts the most in daily work. When the detail changes, the change climbs the chain of dependencies, and every layer above has to be rewritten. The figure below compares the same path with and without an interface.

Without inversion, the use case imports the SQL repository. With it, the code arrows meet at the interface, and only the call goes down to the database.
figure 1 · Control arrow and dependency arrow, with and without inversion

In the left column, the use case points directly to the concrete class, and any change in it reaches the use case. In the right column, the use case points to the interface, and the SQL repository also points to it. The next figure shows what happens when a column changes in the database, the most common scenario in daily work.

Without an interface, a database column change climbs through the repository, the use case and the handler. With an interface, it stops at the adapter.
figure 2 · A database change climbs the chain, or stops at the interface

In the left column, the column change climbs through every layer: the repository has to change its types, the use case changes what it receives, and the handler changes the signature it exposes. In the right column, the change stops at the SQL repository, because the interface does not need to change to accommodate it. The X on the arrow marks the point where the package carrying the change is blocked.

2. Control arrow and dependency arrow

The part of the principle that is hardest to grasp is that it does not change the order in which things happen. When the system runs, the use case still calls the repository, and the database still receives the INSERT. That direction is the control arrow, the one that follows the path of execution.

The dependency arrow is something else. It is what the source code says about who knows whom: which file imports which type, who mentions which name. In a common design, both arrows point the same way, because the use case calls the database and also imports it. Inversion separates the two. The call still goes down to the database, but the code arrow goes up and converges on the interface.

That is why saying the principle “inverts the flow” is imprecise. The flow of control does not change. What changes is the direction of the code dependencies, which used to point to the details and now point to the contract.

Figure 1 shows this separation. In the right column, there are three code arrows: the use case points to the interface, the SQL repository points to the interface, and the yellow arrow, which represents the call at runtime, goes down from the use case to the repository. When you look at the drawing, first look at the purple arrows, which converge on the interface, and then at the yellow one, which shows who actually executes Salvar.

3. The original statement

In the 1996 article, Martin writes two rules. Freely translated, they say the following:

A. High-level modules should not depend on low-level modules. Both should depend on abstractions.

B. Abstractions should not depend on details. Details should depend on abstractions.

In the original, the rules are in capital letters, as was common in technical articles of the time. The translation above is my own.

To read these rules, we need four terms. A high-level module is one that decides what the system does, such as the rule that an order can only be approved if it is pending. This kind of decision is called policy. A low-level module is one that decides how something is done, such as building an SQL query, opening a socket or writing to a file. This kind of decision is a detail. An abstraction is a contract that says what can be requested, without saying how it is done. In Go, Java and TypeScript, this contract is usually an interface. In Kotlin, it can be a regular interface or a fun interface, which has a single abstract method.

Rule A is the one that appears in almost every summary of the principle, and it talks about dependencies between modules. Rule B is the less obvious one, and it talks about the abstraction itself. An example helps. Suppose an interface called RepositorioPedidos, with a method Executar(sql string) error. It satisfies the letter of rule A, because the use case depends on it and not on the database. But the interface carries SQL, which is a detail, and so it violates rule B. Replacing the database with a store that does not speak SQL forces you to rewrite the interface, and with it the whole use case.

Martin explains why he chose the word “inversion”. The structured methods of the time, he says, created hierarchies in which high-level modules called low-level modules, and abstractions depended on details. The structure of a well-designed object-oriented project should be the opposite. The sentence in the article that sums this up is direct: it is the high-level modules that should force the low-level ones to change, and not the other way around.

4. Examples in Go, Java, Kotlin and TypeScript

The four examples below deal with different domains, on purpose: orders, user registration with notification, session expiration and payment charging. The pattern shows up in all of them, and the language only changes how it is written. The code keeps the Portuguese names and comments of the original lesson: Pedido is an order, Servico is the service (the use case), Repositorio is the repository, Salvar is save, Buscar is find, and NovoServico is the constructor of the service. The snippets were run on the versions indicated in each subsection, and the output shown is the real one.

Go: order and repository

In Go, the interface is declared in the package that uses it, not in the package that implements it. The rule comes from the language’s style guide, which says that interfaces generally belong to the package that uses values of the interface type, not to the package that implements those values. Section 5 explains why this choice matters. For now, look at the code.

// pacote pedido/pedido.go
package pedido

import "errors"

// Pedido é a entidade do domínio. Não sabe de banco nem de HTTP.
type Pedido struct {
	ID    string
	Valor int
}

// ErrValorInvalido e ErrPedidoNaoEncontrado fazem parte da linguagem do negócio.
var (
	ErrValorInvalido       = errors.New("valor do pedido deve ser positivo")
	ErrPedidoNaoEncontrado = errors.New("pedido não encontrado")
)

// Repositorio é declarado pelo pacote que USA a interface, não pelo que a implementa.
type Repositorio interface {
	Salvar(p Pedido) error
	Buscar(id string) (Pedido, error)
}

// Servico é o caso de uso. Depende só da interface.
type Servico struct {
	repo Repositorio
}

// NovoServico recebe a dependência pelo construtor (injeção por construtor).
func NovoServico(repo Repositorio) *Servico {
	return &Servico{repo: repo}
}

func (s *Servico) Registrar(p Pedido) error {
	if p.Valor <= 0 {
		return ErrValorInvalido
	}
	return s.repo.Salvar(p)
}

func (s *Servico) Consultar(id string) (Pedido, error) {
	return s.repo.Buscar(id)
}

The pedido package does not import anything about databases. It knows the entity, the business errors, the interface and the use case. The adapter, which lives in another package, is the one that depends on the core, and not the other way around.

// pacote postgres/repositorio.go
package postgres

import (
	"database/sql"
	"errors"

	"example.com/loja/pedido"
)

// Repositorio implementa pedido.Repositorio sem que o pacote pedido precise importá-lo.
type Repositorio struct {
	DB *sql.DB
}

func (r *Repositorio) Salvar(p pedido.Pedido) error {
	_, err := r.DB.Exec(`INSERT INTO pedido (id, valor) VALUES ($1, $2)`, p.ID, p.Valor)
	return err
}

// Traduz o erro do banco para o erro do negócio, para que sql.ErrNoRows não atravesse a porta.
func (r *Repositorio) Buscar(id string) (pedido.Pedido, error) {
	var p pedido.Pedido
	err := r.DB.QueryRow(`SELECT id, valor FROM pedido WHERE id = $1`, id).Scan(&p.ID, &p.Valor)
	if errors.Is(err, sql.ErrNoRows) {
		return pedido.Pedido{}, pedido.ErrPedidoNaoEncontrado
	}
	return p, err
}

The postgres package imports pedido to use the type Pedido, and that is the only import direction between the two. Note that Repositorio does not mention the interface anywhere. It satisfies pedido.Repositorio because it has the right methods. The language specification says that a type implements an interface when it belongs to the interface’s type set, which in practice means having the methods with the required signatures.

// pacote main: a raiz de composição
package main

import (
	"database/sql"
	"fmt"

	"example.com/loja/pedido"
	"example.com/loja/postgres"
)

// main é a raiz de composição: é o único lugar que conhece as implementações concretas.
func main() {
	var db *sql.DB // em produção, sql.Open com o driver
	repo := &postgres.Repositorio{DB: db}
	servico := pedido.NovoServico(repo)
	fmt.Println("serviço montado:", servico != nil)
}

To test the use case without a database, a fake that implements the interface with a map is enough. The snippet below comes from the test file of the pedido package, and section 6 returns to it.

// repoFalso é um fake: implementa a interface com um mapa em memória.
type repoFalso struct {
	pedidos map[string]Pedido
}

func (r *repoFalso) Salvar(p Pedido) error {
	r.pedidos[p.ID] = p
	return nil
}

func (r *repoFalso) Buscar(id string) (Pedido, error) {
	p, ok := r.pedidos[id]
	if !ok {
		return Pedido{}, ErrPedidoNaoEncontrado
	}
	return p, nil
}

With these files, go vet ./... reports nothing, and go test ./... passes both tests of the pedido package. The first confirms that a zero value does not reach the repository. The second confirms that a saved order can be queried.

Java: registration and notifier

In Java, the interface is usually declared in the package of the business rule and implemented with implements. The example below fits in a single file, so the types appear without a package. The registration depends on Notificador (the notifier), and not on a concrete sending mechanism.

import java.util.ArrayList;
import java.util.List;

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

class CadastroUsuario {
    private final Notificador notificador;

    CadastroUsuario(Notificador notificador) {
        this.notificador = notificador;
    }

    void cadastrar(String email) {
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("e-mail inválido");
        }
        // grava o usuário (fora do exemplo)
        notificador.enviar(email, "Bem-vindo!");
    }
}

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

class NotificadorEmMemoria implements Notificador {
    final List<String> enviados = new ArrayList<>();

    @Override
    public void enviar(String destino, String mensagem) {
        enviados.add(destino + " | " + mensagem);
    }
}

public class Main {
    public static void main(String[] args) {
        // produção: a raiz de composição escolhe o detalhe
        CadastroUsuario emProducao = new CadastroUsuario(new EmailNotificador());
        emProducao.cadastrar("ana@exemplo.com");

        // teste: o mesmo caso de uso, com um fake no lugar do e-mail
        NotificadorEmMemoria fake = new NotificadorEmMemoria();
        CadastroUsuario noTeste = new CadastroUsuario(fake);
        noTeste.cadastrar("bia@exemplo.com");
        System.out.println("enviados no teste: " + fake.enviados);
    }
}

Compiled with the javac of JDK 21.0.8, the program prints e-mail para ana@exemplo.com: Bem-vindo! and then enviados no teste: [bia@exemplo.com | Bem-vindo!]. The second line shows that the same CadastroUsuario worked without any change, only with another implementation. In Java, the interface does not need to be in the same package as the implementation, but the convention of declaring it on the consumer’s side applies here too, for the same reason as in Go.

Kotlin: session and clock

A classic case of hidden dependency is time. Whoever writes Instant.now() inside the business rule makes the test depend on the computer’s clock. In the example below, the session receives a clock from outside, and the Relogio interface (clock) is a fun interface, which accepts a lambda in place of a class.

import java.time.Duration
import java.time.Instant

fun interface Relogio {
    fun agora(): Instant
}

class Sessao(
    private val criadaEm: Instant,
    private val duracao: Duration,
    private val relogio: Relogio,
) {
    fun expirada(): Boolean = relogio.agora().isAfter(criadaEm.plus(duracao))
}

fun main() {
    val criadaEm = Instant.parse("2026-01-01T10:00:00Z")
    val duracao = Duration.ofMinutes(30)

    // teste: o relógio é uma função que devolve o instante que o teste quiser
    val depois = Sessao(criadaEm, duracao, Relogio { criadaEm.plusSeconds(31 * 60) })
    val dentro = Sessao(criadaEm, duracao, Relogio { criadaEm.plusSeconds(10 * 60) })
    println("expirada após 31 min: ${depois.expirada()}")
    println("expirada após 10 min: ${dentro.expirada()}")

    // produção: o detalhe real, o relógio do sistema
    val real = Sessao(Instant.now(), duracao, Relogio { Instant.now() })
    println("expirada agora: ${real.expirada()}")
}

Compiled with the kotlinc 2.1.21 that ships with IntelliJ and run with java -cp sessao.jar SessaoKt, the program prints expirada após 31 min: true, expirada após 10 min: false and expirada agora: false. The file is called Sessao.kt, and the class of top-level functions is generated as SessaoKt, which is why the run uses that name. It is worth noting that the Java standard library already provides this abstraction, in java.time.Clock, with methods such as Clock.fixed(Instant, ZoneId) for tests. Before creating your own interface for time, check whether the library already solves the case.

TypeScript: checkout and gateway

In TypeScript, the contract is structural. A class satisfies an interface if it has the right members, even without declaring implements. The code below declares implements on purpose, because the declaration makes the compiler flag any method missing from the fake or from the real implementation.

interface GatewayPagamento {
  cobrar(valor: number): Promise<string>;
}

class Checkout {
  private gateway: GatewayPagamento;

  constructor(gateway: GatewayPagamento) {
    this.gateway = gateway;
  }

  async finalizar(valor: number): Promise<string> {
    if (valor <= 0) {
      throw new Error("valor inválido");
    }
    return this.gateway.cobrar(valor);
  }
}

class GatewayFalso implements GatewayPagamento {
  async cobrar(valor: number): Promise<string> {
    return `cobranca-falsa-${valor}`;
  }
}

const checkout = new Checkout(new GatewayFalso());
checkout.finalizar(42).then((id) => console.log("cobrança:", id));

Run with node checkout.ts on Node 24.21.0, the program prints cobrança: cobranca-falsa-42. Node runs .ts files only by removing the types, without compiling them. For that reason, the shorthand constructor form, with private gateway in the parameter itself, does not work in this run: the syntax cannot be removed, and Node refuses it. The example writes the field and the assignment by hand, and that is the form shown above.

5. How it works inside

Applying the principle to a real case follows five steps, and they repeat in every language in the examples. The first is to identify the dependency you want to invert, that is, the concrete type the use case imports today. The second is to declare the interface on the consumer’s side, with only the methods the use case needs. The third is to implement that interface in the adapter, with the concrete technology. The fourth is to inject the implementation through the constructor, instead of letting the use case create it. The fifth is to assemble everything in a single place, the composition root.

Dependency injection is the name of the technique in the fourth step: the object receives the dependency from outside, instead of creating it with new or with a global call. The composition root is the part of the program that knows all the concrete implementations and wires the pieces together. In Go, it is the main function. In larger applications, it can be a set of assembly functions or an injection container. It should be the only place in the code that knows which implementations exist.

Figure 3 shows the assembly in sequence. main reads the configuration, creates the SQL repository, hands the repository to NovoServico, and the service stores the interface in a field. The handler receives the service already ready. The arrows show the order in which the pieces are assembled, from the outside in.

main creates the SQL repository and hands it to NovoServico, which keeps the interface in a field. The service never creates its own dependency.
figure 3 · Constructor injection: the composition root builds the graph from outside in

In the figure, the use case appears with the interface and nothing else. The SQL repository appears with the dotted arrow that says “implements”, and the arrow that links main to the service carries the repository as an argument. The detail to observe is that no arrow leaves the use case toward PostgreSQL. The only link between the core and the database goes through the assembly done at the root.

Now go back to the Go rule from section 4: the interface belongs to the package that uses it. Figure 4 shows why this choice works. The three packages appear side by side, with main at the top assembling the pieces.

In Go, the Repositorio interface lives in the pedido package. The postgres package satisfies it without importing it, so a direct import would form a cycle.
figure 4 · In Go, the interface lives in the package that uses it, and the adapter satisfie

In the figure, the pedido package declares the interface, and the service depends on it. The postgres package holds the concrete struct, which satisfies the interface without importing it, and that is why the arrow is dotted. The bottom arrow, which goes from the service to postgres and is blocked, shows the import the compiler refuses. When the interface lives in the adapter’s package, the use case needs to import the adapter to see the contract, and the cycle comes back.

The next figure shows the cycle without disguise. It compares the drawing with a cycle and the drawing without one, with the same two packages.

Without an interface, pedido and postgres import each other, and go build refuses with import cycle not allowed. With it, only postgres imports pedido.
figure 5 · Go import cycle: the compiler refuses it, and the interface breaks the cycle

On the left, the forward arrow, pedido importing postgres, passes, and the return arrow, postgres importing pedido, appears blocked. The go build card shows the message the compiler returns. On the right, the interface absorbs the dependency, and the only import left goes from postgres to pedido. In Java and TypeScript, the same structure compiles, and the difference shows up over two years of maintenance, when someone tries to separate the modules and finds that they cannot.

6. Test doubles without touching the core

The main practical benefit of the principle is usually testing. Because the use case depends on an interface, the test can hand in any implementation that meets the contract. The in-memory fake in the Go example is a complete implementation, only without a database. It stores the orders in a map and answers with the business error when the order does not exist.

The two tests of the pedido package show how it is used. The first creates a service with the fake, tries to register an order with value zero and confirms that the error ErrValorInvalido was returned and that the map is still empty. The second registers a valid order, queries the same identifier and confirms the value. A third test, if you want, would query a nonexistent identifier and check ErrPedidoNaoEncontrado. None of the tests start a database, and they run in milliseconds.

The figure below shows the swap. Both paths use the same service and the same interface, and only the implementation handed in changes.

The test and main call the same service. In the test, an in-memory fake implements the interface; in production, the SQL repository implements it.
figure 6 · The same use case with a fake in the test and the SQL adapter in production

In the drawing, the test and main call the same service, which means the tested logic is the production logic. The two bottom branches show what changes: repoFalso keeps the data in memory, and the SQL repository talks to PostgreSQL. The top part and the interface in the middle do not change between the two cases.

One caution is needed. A fake is a promise that the real implementation behaves the same way. If the fake accepts everything and the database rejects, the test passes and production breaks. That is why the adapter also needs its own tests, which run against the real database, to verify the translation to SQL and the mapping of errors. The principle separates responsibilities, but it does not replace either of the two checks.

7. The detail that leaks through the port

An interface can be well designed and still depend on a detail. The most common case is the error. If the repository returns the database error without translating it, the use case starts to know that error, and with it the library that defines it. The snippet below is the kind of code that appears when this happens.

// pacote pedido, com o vazamento
package pedido

import (
	"database/sql"
	"errors"
)

// O caso de uso importa database/sql só para reconhecer o erro do banco.
func buscarOuDesistir(err error) string {
	if errors.Is(err, sql.ErrNoRows) {
		return "não existe"
	}
	return "erro"
}

The code compiles, and that is exactly the problem. The pedido package now imports database/sql, and any change of database that returns a different error forces the use case to change. The fix belongs in the repository, which translates sql.ErrNoRows to pedido.ErrPedidoNaoEncontrado before returning the error. This translation appears in the Buscar method of the postgres package, shown in section 4.

The figure below compares the two paths. At the top, the error crosses the port without translation and the service imports database/sql. At the bottom, the repository translates the error and the service compares it with the business error.

Two error paths: sql.ErrNoRows crosses the port and the service imports database/sql, or the adapter translates it into the business error.
figure 7 · A database error leaking through the port, and a business error crossing it

In each group, the bottom arrow shows the implementation and the top arrow shows the service’s dependency. In the “Leaked” group, the pink arrow carries sql.ErrNoRows up to the service, and the service card turns pink because it started depending on the database. In the “Translated” group, the green arrow carries the business error, and the service keeps depending only on the port.

The same kind of leak appears with other technical objects: a query result from an ORM, a response type from an HTTP client, an exception specific to a driver. In all of them the rule is the same. The port uses only domain types and primitive types, and the adapter translates everything technical before it crosses the boundary.

8. Inversion of control, injection and what people often confuse

Three terms show up together all the time and are often confused. The first is the Dependency Inversion Principle, which is the subject of this lesson and is about the direction of code dependencies. The second is dependency injection, a technique for handing a dependency in from outside, usually through the constructor. The third is inversion of control, which describes a situation in which someone from outside, such as a framework, calls the application’s code instead of the code calling the libraries.

Martin Fowler wrote about these subjects in 2004. He observed that the term inversion of control was too generic and proposed calling the pattern dependency injection, a more specific name for the form of inversion he was describing. In practice, whoever talks about injection is talking about a mechanism that is also a form of inversion of control. Whoever talks about dependency inversion is talking about a rule about source code, which can be followed with or without automatic injection.

The figure below shows the relationship between the four concepts and the tools that usually appear alongside them.

Relationship map: DIP is realized with interfaces and made possible by dependency injection, a specific form of inversion of control.
figure 8 · How DIP, interfaces, injection and inversion of control relate

In the figure, DIP appears at the top, because it is the rule that guides the others. Programming to interfaces is how you write the contracts, and hexagonal architecture applies the principle at the edges of the system. Dependency injection is the mechanism that assembles the pieces, and inversion of control is the broader form in which it fits. Tools such as Wire, Fx and Spring automate the assembly, but none of them creates the principle. You can apply DIP fully in Go with a main written by hand, as in the examples.

If you work with Go, you will meet the term injection in two related posts, currently only in Portuguese. One covers the subject in depth, including the difference between DIP and DI, and the other shows the version that generates the assembly code at compile time with Google Wire.

9. Common mistakes and good practices

The first mistake is creating an interface before having two uses for it. It is born “just in case”, with the same name as the struct and a single implementer. The result is one more file, one more indirection and no isolation benefit, because nobody needs to swap anything. The good practice is to ask which dependency you want to replace or isolate in tests. If the answer is none, the interface can wait. This subject has a longer treatment in the post on hexagonal architecture.

The second mistake is declaring the interface in the adapter’s package. In Go, this usually ends in an import cycle, as we saw in section 5. In other languages, it ends in a dependency inversion in name only: the use case starts importing the module that should sit below it, just to see the contract. The good practice is to declare the interface in the package that uses it.

The third mistake is leaking types through the port, already shown in section 7. The interface looks clean, but it returns a database error, an ORM object or an HTTP client type. The good practice is to translate everything at the edge, so that the core sees only the vocabulary of the business.

The fourth mistake is an interface that is too large. Rob Pike, in a list of Go proverbs presented in 2015, wrote that the bigger the interface, the weaker the abstraction. The Go standard library shows the opposite path: io.Reader and io.Writer have one method each. An interface with ten methods forces every fake to implement ten methods, and every new implementer to keep up with all of them. The good practice is to design the interface from the calls the use case makes, not from what the adapter is able to do.

The fifth mistake is new inside the use case. Creating a repository with new inside the service seems harmless, and that is exactly why the dependency hides in a place nobody looks. When the service creates its own dependency, the test cannot replace it, and the composition root loses the power to decide what the system uses. The good practice is to receive the dependency through the constructor, even when the application is small.

The sixth mistake is thinking that an injection container does the design work. A framework assembles the object graph, but it does not decide which interfaces exist or where they live. A project can use a container and still have the use case importing the database. The good practice is to design the interfaces before choosing the tool, and to treat the tool as a detail.

The seventh mistake is never checking the rule. The direction of dependencies is easy to break with a stray import, and code review often lets it pass. The good practice is to turn the rule into an automatic check. In Go, the compiler already prevents cycles. In Java, the check is usually done with ArchUnit. Whoever takes that route should plant a violation on purpose and watch the check fail, because a rule that never broke does not prove that it is active.

10. When not to use it

Not every dependency needs an interface. Standard library functions, pure operations without side effects and stable utilities, such as a function that formats a number, rarely benefit from an abstraction. Whoever replaces strings.ToUpper with a ConversorTexto interface spends reading, files and ceremony without gaining anything. The same goes for a one-off script, a prototype that will be thrown away in two weeks, and a system with a single implementation that will never change.

The most useful criterion is to ask whether the dependency changes or is expensive to run in a test. A database, an email provider, a clock and a payment gateway usually pass both criteria, which is why they appear so often in the examples. A pure function, which only calculates, passes neither. When the answer is no, the interface is ceremony, and the best architecture for that piece of code is usually the simplest one that still keeps the code easy to read.

11. Exercises

  1. Take a use case of yours, or the Servico of the Go example. List its dependencies and mark each one as policy or detail. For each detail, write the interface you would declare on the use case’s side, and check that it has only the methods the use case calls.

  2. In the Go example, add the method Cancelar(id string) error to the Repositorio interface and run go vet ./.... Go points out the error in two places: in the fake of the test file and in main.go, on the line where NovoServico receives the repository. Explain why the error does not appear in the postgres package, which does not implement the method yet, and why the use case did not need to change.

  3. Write a fake that always returns an error in Salvar, and a test that confirms Registrar returns that error and leaves nothing saved. Then reflect on what this test does not verify, and in which other test that part should be covered.

  4. In Java, make the class CadastroUsuario import and instantiate EmailNotificador directly, instead of receiving the interface through the constructor. Compile the code and confirm that the compiler accepts the change. Then answer: which tool, or which test, should refuse this change, and why is the compiler not enough for that?

12. Glossary

Dependency Inversion Principle (DIP): principle that asks high-level and low-level modules to depend on abstractions, and abstractions not to depend on details.

High-level module: part of the system that decides what the business does, also called policy. It should not depend on technical details.

Low-level module: part that decides how something technical is done, such as SQL queries, sockets or files. Also called a detail.

Abstraction: contract that describes what can be requested, without saying how it is done. In Go, Java and TypeScript, usually an interface.

Control arrow: the direction in which program execution passes from one part to another, at runtime.

Dependency arrow: the direction in which the source code mentions types and packages, that is, who knows whom in the code.

Dependency injection: technique in which an object receives the dependencies it needs from outside, usually through the constructor, instead of creating them.

Composition root: the place in the program that knows all the concrete implementations and assembles the pieces. In Go, usually the main function.

Inversion of control: situation in which a framework or other external code calls the application’s code, instead of the application calling the libraries.

Fake: simplified but functional implementation of an interface, used in tests. A repository that stores data in a map is an example.

Abstraction leak: when the interface exposes a type or an error from the detail, and the core starts depending on it.

sources

Consulted on November 2, 2026:

  • Robert C. Martin, “The Dependency Inversion Principle”, Engineering Notebook column, C++ Report, 1996. PDF copy: https://cs.utexas.edu/~downing/papers/DIP-1996.pdf. Rules A and B, my free translation of them, the keyboard-to-printer example and the motivation for the name “inversion” come from this source. The publication month was not checked, because the references consulted disagree between May and June 1996.
  • Go Code Review Comments, Interfaces section: https://go.dev/wiki/CodeReviewComments. The rule about the package that uses the interface was checked through a summary of the page, not through a literal copy of the HTML in this consultation.
  • Rob Pike, Go proverbs (2015): https://go-proverbs.github.io/. The sentence “The bigger the interface, the weaker the abstraction” was checked through a summary of the page.
  • Martin Fowler, “Inversion of Control Containers and the Dependency Injection pattern”, January 23, 2004: https://martinfowler.com/articles/injection.html. The position on the generic term inversion of control comes from this source.
  • Go language specification, section on implementing interfaces, in the installed toolchain version (go1.27.2), in doc/go_spec.html, checked locally.
  • Documentation of io.Reader and io.Writer in the Go standard library, checked with go doc in toolchain 1.27.2.
  • Documentation of java.time.Clock in JDK 21.0.8, checked with javap.

Run on November 2, 2026: the Go examples (go1.27.2, with go vet, go test and the cycle message), the Java examples (javac and java from Temurin 21.0.8), the Kotlin example (kotlinc 2.1.21 from IntelliJ) and the TypeScript example (Node 24.21.0, with type removal) ran, and the outputs quoted in the text are the real ones. Exercise 2 was tested with the quoted error message.

Not verified in this review: the acronym SOLID, which secondary sources attribute to Michael Feathers around 2004, without a checked primary source. The lesson does not depend on that date. Also not checked, in a primary source, is Martin’s statement in the 2002 book, which the lesson does not cite.

  • Hexagonal architecture
  • Programming to interfaces
  • Dependency injection
  • Dependency injection with Google Wire
  • Hexagonal architecture with ArchUnit
  • Clean Architecture in Go
  • Testing with Kotest, MockK and Testcontainers
  • Go
  • Structs
  • PostgreSQL

↑ Back to top

Keyboard shortcuts

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