Software architecture describes constraints on how data flows through your application. Good architecture puts an emphasis on low coupling, high maintainability and great extensibility.
This blog entry provides an example on how to build any software in a reasonable way.
Technology Independence
What matters most to your software is the problem that it is supposed to solve. Your solution is the core of your software and it is likely technology independent.
Please do not be overwhelmed by code syntax, millions of frameworks and libraries. They are a distraction of what matters.
Don't let yourself be blinded by frameworks! They are tools that have to support your solution.
The Layered Architecture
A short disclaimer, this approach to software architecture is the practical implementation of domain driven design principles mixed with onion architecture.
I did not invent these design principles. What I present here is my practical understanding of them.
Good software needs three layers: Infrastructure, Application and Domain. That's it.
In the layered architecture, observe these simple rules:
- Each layer must only invoke functions of the next layer
- Infrastructure invokes application functions
- Application invokes domain functions
- Domain is self-contained
- Infrastructure contains your technology dependent code
- Domain implements business rules by providing models and functions
- Application exposes the business api by orchestrating domain functions
Infrastructure
The outermost layer of your software. Everything that goes in and out of your software has to go through this layer.
You want to handle requests? Put a controller, request handler or however you want to call it in the infrastructure layer.
You want to persist your objects? Put a repository into your infrastructure layer.
You want to encrypt your users' passwords? Put an encryption service (like BCrypt) into your infrastructure layer.
You want to put messages on a message bus? You want to sign a token? You want to.... You should know the drill by now.
Infrastructure contains your technology dependent code.
This layer will likely change a lot throughout the lifespan of your software. If you upgrade libraries or replace libraries, you'll work on the infrastructure layer.
Application
The middle layer of your software. The application layer exposes the business api to the infrastructure layer. I always think about which use cases my software has to implement, each of these use cases should be a single function in this layer.
You want to create a blog post? Add a createBlogPost function.
You want to register a user? Add a registerUser function.
This is straight forward enough, but I cannot emphasize this pattern enough! Translate each step of any use case into functions of your application layer.
fun registerUser (...) { ... }
fun signIn (...) { ... }
fun confirmEmail (...) { ... }
fun signOut (...) { ... }
You define functions for each business use case or steps within a use case into the application layer. When implementing, remember the rule:
Each layer must only invoke functions of the next layer, Infrastructure invokes application functions, application invokes domain functions
So in the application layer, you're being called by the infrastructure and you in turn call the domain. Then you return the result to the infrastructure.
Let's implement a non-trivial function registerUser. What are the business rules for it?
- Username must be a valid email address
- Username must be unique
- Password must be encrypted
- Confirmation mail must be sent
fun registerUser (username: EmailAddress, password: String) {
userRepository.assertUniqueness(username)
const encryptedPassword = passwordService.encrypt(password)
const user = User(username, encryptedPassword)
userRepository.save(user)
mailService.sendEmailConfirmation(user)
return user
}
Application exposes the business api by orchestrating domain functions.
We use the building blocks provided by the domain to implement the use case. The use case just calls the domain in the correct order. The register user function itself does not contain any business logic.
Keep in mind that passwordService
, userRepository
and mailService
are just interfaces exposed by the domain. If you want
functionality, but you do not want to implement it, create an interface in the domain.
Domain
The innermost layer of your software. This layer should not have any external dependencies. It contains all building blocks that are required to solve the business problem at hand.
The registerUser
use case is not the solution to your business problem, but it may be a step that needs to be taken in order to reach the solution.
What are you supposed to do to achieve your goal (completing the story in this case)?
Let us go through each business rule.
Business rule 1: Username must be a valid email address
We decide that we want to validate email addresses ourselves. In order to do this we need an object that holds this validation logic. The most straight forward approach is to translate this rule directly into our domain code.
class EmailAddress(val value: String) {
init {
if (!this.value.matches(REGEX)) {
throw IllegalArgumentException("emailAddress.error.invalidFormat")
}
}
companion object {
private val regex = Regex("(?:[a-z0-9!#\$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#\$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])")
}
}
Great! We have written validation logic ourselves! That justifies our own email address representation! One thing is missing though. We were supposed to ensure that the username is a valid email address. We need to create a user here.
class User(
val name: EmailAddress,
val password: String,
val id = UUID.randomUUID()
) {
init {
if (this.password.isBlank()) {
throw IllegalArgumentException("user.error.passwordRequired")
}
}
}
Done! Our user ensures that his name is a valid email address. As responsible developers we ensure that the password is not blank. I snuck a little id into the user model. This is in general a good idea. Do not rely on business keys alone, like the username here. Add a technical id to everything that your software wants to identify, do not rely on any external business keys for that. I recommend creating ids in your software, do not let them be generated by a database. Your software owns the model! Do not let anybody manipulate it magically! Keep authority over it.
Business rule 2: Username must be unique
Who can we ask if a username is unique in our system? We could store each user in a list and filter the list. This could be a valid approach for applications but in our case we want our user data to survive an application restart. We want to be prepared for lots and lots of users. There are tools that give us these features. Our software solution would benefit from a database, but we do not want to invent a new database. We should create an interface that expresses this need.
Create interfaces for features that you want to use, but do not want to implement.
interface UserRepository {
// @throws IllegalStateException if username is already taken
fun assertUniqueness(username: EmailAddress)
}
The domain should not care how this is implemented, it is not crucial to your software solution.
The infrastructure layer could then implement this interface with a PostgresUserRepository
, MongoUserRepository
, MySqlUserRepository
or
with any technology it likes.
Business rule 3: Password must be encrypted
Same drill here. We do not want to invent a safe new way to encrypt passwords. But we want to utilise existing implementations.
interface PasswordService {
fun encrypt(password: String): String
fun matches(rawPassword: String, encryptedPassword: String): Boolean
}
The infrastructure can then provide a BCryptPasswordService
or what ever gets the job done.
Business rule 4: Confirmation email must be sent
This is getting redundant. I'll provide the interface that the domain should expose.
interface MailService {
fun sendConfirmationMail(user: User)
}
Somewhere in the infrastructure layer provide an implementation for it.
We developed a model and a set of functions that ensure all business requirements.
Domain implements business rules by providing models and functions.
The domain is the most important part of your software. Every other layer just supports the domain. When implementing a new use case I suggest starting with the domain and develop inwards out. Think about what business rules exists and create your models accordingly. Ensure that business rules are never broken.
Conclusion
With great software architecture you can ensure that your software can evolve with changing business requirements. It simplifies onboarding for new developers and provides structure for code reviews. After all, software architecture is only a set of rules. You are able to tell new developers what rules to follow and can exactly tell them when something is wrong. These things are not a matter of taste anymore.
I hope this blog sparked some interesting thought and will benefit your daily work.
Kommentar schreiben