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.

The Dreaded Technology

Software development is NOT about technology. Please do not be overwhelmed by code syntax, millions of frameworks and libraries. They are a distraction of what matters.

What matters is the problem that your software is supposed to solve. Your solution is the core of your software and it is likely technology independent.

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 3 layers, that's it.

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 is supposed to change a lot. If you upgrade libraries or replace libraries, the work that results from that should happen in the infrastructure layer.

Application

The middle layer of your software. Is called by the infrastructure layer. It exposes the business api to the infrastructure layer. Everything that your software offers should be present here. I always think about which use cases my software has to implement, 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 can not emphasize this pattern enough! Translate your use case and each step it includes directly 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. But how do you implement them? Let me state a rule that has to be obeyed here:

Each layer must only depend on the next layer, Infrastructure depends on Application, Application depends on Domain

If you think about how to implement an application function keep that rule in mind.

Lets implement a non trivial function registerUser. What are the acceptance criteria for it?

  • Username must be a valid email address
  • Username must be unique
  • Password must be encrypted
  • Confirmation mail must be send

Fair enough, let's go on to the implementation. I sure hope you haven't forgotten the rule yet. In case you did: ONLY DEPEND ON THE NEXT LAYER!

fun registerUser (username: EmailAddress, password: String) {
  userRepository.assertUniqueness(username)
  
  const encryptedPassword = encryptionService.encrypt(password)
  const user = User(username, encryptedPassword)
  
  userRepository.save(user)
  mailService.sendEmailConfirmation(user)
  return user
}

Here we depend on external services like userRepository, encryptionService and mailService. We want to register users, but we do not want to invent encryption, mailing or persistence. In these cases the domain layer (which is the innermost layer) should expose these services as interfaces to the application layer.

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 final solution.

What are you supposed to do to achieve your goal (completing the story in this case)?

Let us go through each acceptance criteria.

Acceptance criteria 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 criteria directly into your 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 to create 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. Always.

Acceptance criteria 2

Username must be unique

Who can we ask if an email 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.

Acceptance criteria 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.

Acceptance criteria 4

Confirmation mail must be send

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.

Conclusion

With great software architecture you can ensure that your software can evolve with changing business requirements. It simplifies onboarding for new developers and gives structure 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. Following rules gives everything structure and creates valuable discussions.

I hope this blog sparked some interest and could benefit your daily work.