· 

The Definitive Guide To Software Architecture

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 passwordServiceuserRepository 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 PostgresUserRepositoryMongoUserRepositoryMySqlUserRepository 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

Kommentare: 0