Summary
By the end of this guide, you'll have everything you need to write ironclad Rust applications. I will get you writing the most maintainable Rust of your life.
The Very Bad Application is the most common way to write production services. Your company will have code that looks just like it. By studying a Very Bad application, you'll see the problems that hexagonal architecture fixes clearly.
We're concerned about architecture, so I've omitted details like a panic recovery layer, the finer points of tracing, graceful shutdown, and most of the routes a full app would have. We have one route: POST /authors, for creating blog post authors. Finally, it binds a Tokio TcpListener to the application port and fires up the server.
This isn't a leaky abstraction, it's a broken dam. See how we pass sqlite into axum as a field of AppState 3 to make it accessible to our HTTP handlers? To change your database client – not even to change the kind of database, just the code that calls it – you'd have to rip out this hard dependency from every corner of your application.
Integration tests are slow and expensive – they aren't suited to exhaustive coverage. Tokyo is a hard dependency of most production Rust applications. An async runtime is a dependency on a grand scale, practically part of the language itself.
Hexagonal architecture brings order to chaos and flexibility to fragile programs. It makes it easy to create modular applications where connections to the outside world always adhere to your business API.
Hard-coding your handler to manage SQL transactions will come back to bite you if you switch to Mongo. Code that needs a database doesn't need to know how that database is implemented.
AuthorRepository is what's known as a domain trait. Port is a point of entry to your business logic. A concrete implementation of a port is called an adapter.
If you don't construct a valid CreateAuthorRequest from the raw parts you've received over the wire, you can't call AuthorRepository::create_author. This pattern of newtyping should be familiar if you've read The Ultimate Guide to Rust Newtypes.
Serde describes a general data format for serialization and deserialization. It doesn't commit you to any particular IO format like JSON, XML, etc. Using Serde won't necessarily result in a tight coupling between domain and adapter.
Data required to fully represent a customer is extensive. It can take many weeks to collect it and make an account fully operational. It would be brittle and inefficient to pass such a needlessly large struct.
Exhaustive vs. non-exhaustive enums. CreateAuthorError doesn't define failure cases such as an input name being invalid. This is the responsibility of the CreateAuthorRequest constructor.
This is incredible news for callers of domain traits – immensely powerful. Any code calling a port has a complete description of every error scenario it's expected to handle. The compiler will make sure that it does.
Anyhow is an excellent crate for working with non-specific errors. anyhow:: Result is equivalent to std::result:: Result<T, anyhow:Error. any how:Error says we don't care which error occurred, just that one did.
Sqlite's implementation of AuthorRepository knows all about SQLite error codes. The domain can't do anything useful here, so why not skip the middleman and let the panic recovery middleware handle it?
We need to make AuthorRepository an async trait. Unfortunately, it's not quite as simple as writingpub trait AuthorRep repository: Send. Since our Author and CreateAuthorError are both Send, a Future that wraps them can be too 20.
Arc requires its contents to be both Send and Sync to be either Send or Sync itself. The web server, axum, requires injected data to be Clone, giving the final trait definition.
AppState is now generic over AuthorRepository. That is, AppState provides HTTP handlers with access to "some store of author data" This gives them the ability to create authors without knowledge of the implementation.
AuthorRepository is a serializable ApiError. Changing the HTTP request structure doesn't require any change to the domain. Only the mapping in CreateAuthorHttpRequestBody::into_domain and its corresponding unit tests get updated.
Errors into HTTP responsesApiError itself is transformed into an HTTP response with a JSON body by the axum IntoResponse trait. The finer points of how you log the underlying cause will vary according to your needs.
MockAuthorRepository is defined to hold the Result it should return in response to AuthorRepository::create_author calls. The mock implementation of create_author then deals with swapping a dummy value with the real result in order to return it to the test caller.
In part 3, I'll introduce you to the beating heart of an application domain: the Service. We'll confront the tricky problem of master records through the lens of authentication. We will explore the interface between hexagonal applications and distributed systems. And yes, we'll finally answer, "why hexagons?".
A Service refers to both a trait that declares the methods of your business API, and an implementation that's provided by the domain. We've already seen that domain logic doesn't belong in adapters.
AuthorMetrics 34 describes an aggregator of author-related metrics, such as a time series database. AuthorNotifier 35 sends notifications to authors.
The Service struct encapsulates the dependencies required to execute our business logic. The implementation of AuthorService::create_author 37 illustrates why we don't want to embed these calls directly in handler code.
To unit test a Service, you mock each of its dependencies, returning the successes and errors required to exercise all of the paths described above. To unit test handlers that call a Service you just mock the service, returning whatever success or error variant you need to check.
Even though we're using an axum HTTP server, main doesn't know about axum. The less code we put here, the smaller this testing dead zone.
Hexagonal architecture was originally proposed by Alistair Cockburn. The symmetry of hexagons also reflects the duality of inbound and outbound adapters.
The good news is, I have two powerful rules of thumb to help you make the right decision. A domain represents some tangible arm of your business. A single-entity domain makes it easier to teach the concepts of hexagonal architecture.
Author domain manages the lifecycle of an Author. But what about blog posts? If an Author is deleted, do we require that all of their posts are deleted atomically?
Start with a single, large domain rather than designing many small ones upfront. Different domains may share the same underlying data source, but interpret it differently. A fat domain makes no assumptions about how different business functions will evolve over time.
If you're used to working on monolithic apps, it's not obvious where Users belong in hexagonal architecture. If you're comfortable in a microservices context, you'll have an easier time.
In part four of this guide, we'll look at the trade-offs of using hexagonal architecture. We'll also look at how it simplifies the jump to microservices when the time is right.