Structurally Sound Architecture in Rust

Jan 20, 2021

Core Ideas Behind the Architecture

This architecture is based on my ideas as well as some behind domain driven design, the hexagonal architecture, clean architecture, and the onion architecture. The focus of this architecture is:

  1. Loose coupling
  2. Separation of "smart" and "dumb" functionality

There two main concepts in this architecture: models and, for lack of a better word, services. These two concepts are the building blocks for this architecture.

Models

Models are dumb data. They have no dependencies, and any functional aspect only pertains to the data itself, with no side-effects. For example, a user model could have the following data:

  1. Username
  2. Email
  3. Password
  4. Registration date

The functionality of this model can't have any dependencies, only data manipulation. For example, this model couldn't have a save method, because that would require access to some data store. However, this model could have a time_since_registration method which returns how long it's been since the user registered. This requires no other information other than what the user model already has, so it's okay for the model to have this method.

Services

Services are units of functionality. They have a strict API and encapsulate one aspect of your whole domain. They can be dependencies and have dependencies. Services must be black boxes, the inner workings of which are hidden to clients. For example, a service responsible for data saving and retrieving shouldn't expose the necessities of the database it's using to its clients. This also allows for easy swapping of the backend implementation, because of the strict API and encapsulation. Let's take a look at an example of how these services would work:

An authentication service could have the following responsibilities:

  1. User sign up
  2. User login
  3. Session creation
  4. Session authentication

And this service could be dependent on a data store service that would be responsible for:

  1. Saving users
  2. Retrieving users
  3. Saving sessions
  4. Retrieving sessions

Although both have similar responsibilities, for example, session creation and saving, they are in different contexts. The data storage service is responsible for saving the session data. It doesn't care what that data is. Whereas, the authentication service is responsible for creating that data, generating an ID, creation of the expiration date, etc.

This could be taken one step further, and instead of having one service responsible for data storage for all models, there could be a service for each type of model. This would be useful, for example, in the case where we want to have the option to save sessions in a different datastore than users.

Circular Structure

Many architectural patterns—hexagonal architecture, clean architecture, onion architecture—incorporate the idea of a circular structure in which the core is abstract, the outside is real, and the dependency structure points in (i.e. the outside depends on the inside). The core is your business logic, and the outside is how it ties to the real world, databases, UIs, REST APIs, etc. The whole idea is that you don't want your business logic to be coupled to some outside source that could be deprecated, unmaintained or compromised. You want your business logic to live forever and not be dependent on anything. The lack of this is how you get big, expensive, and often unsuccessful, rewrites.

Although this idea isn't ingrained into the architecture (i.e. this architecture makes no effort to enforce the idea), the architecture makes it easy to follow this idea. Everything from the outside world can be abstracted away in a service.

Rust

Although this architecture is capable in just about any language—I have first-hand experience in Node.JS and GoLang—using Rust will give us a good example of the implementation of this design and will let us see its usefulness. This pattern isn't exactly intuitive in Rust but is relatively easy to incorporate.

Exmaple File Structure

src
├── lib.rs
├── models
│   ├── auth.rs
│   └── mod.rs
├── services
│   ├── auth.rs
│   ├── db.rs
│   └── mod.rs
└── utils
    ├── auth.rs
    └── mod.rs

This, of course, is just an example of a file structure, and the isn't too important to the main concepts of this architecture.

Models

Models are easy in Rust, just plain structs. From the same example as above, a user model could look like the following:

// src/models/auth.rs

struct User {
  username: String,
  email: String,
  password: String,
  registration: Date
}

and could have all the dumb functionality as necessary:

// src/models/auth.rs

impl User {
  pub fn time_since_registration(&self) -> Duration {
    self.registration.elapsed()
  }
}

Services

Although a bit more involved than models, services are still quite simple in Rust. Since services should have a strict interface, hide their implementation details, and allow for multiple implementations, traits are perfect. Again from the same example as above, an auth service could look like the following:

// src/serivces/auth.rs

pub trait Auth {
  // RegistrationDetails could be decleard inlined in this file 
  // or in src/models/auth.rs. It's not too important.
  fn user_sign_up(&mut self, registrationDetails: RegistrationDetails): Session;
  fn user_login(&mut self, email: String, password: String): Session;
  fn session_create(&mut self, user: User): Session;
  fn session_valid(&mut self, session: Session): bool;
}

This is our strict API, all implementations must adhere to this interface. To create an implementation for this service interface, we'll create a new private struct that implements Auth:

// src/serivces/auth.rs

// The name isn't important, and if there were more implementations we would
// name them differently.
struct Service {
  // We could design this so we don't need to use an allocation, using a 
  // generic instead, but it just makes things a lot easier and isn't too much 
  // of a cost.
  db: Box<Db>,
}

impl Auth for Service {
  fn user_sign_up(&mut self, registrationDetails: RegistrationDetails): Session {
    // ...
  }
  fn user_login(&mut self, email: String, password: String): Session {
    // ...
  }
  fn session_create(&mut self, user: User): Session {
    // ...
  }
  fn session_valid(&mut self, session: Session): bool {
    // ...
  }
}

To allow clients to create this service implementation without exposing Service, we'll create a public new function:

// src/serivces/auth.rs

pub fn new(db: Box<db>) -> Box<Auth> {
  Box::new(Service {
    db,
  })
}

This is where you could have logic to decide which implementation to use.

Conclusion

This architecture isn't necessarily the be all end all, but I think has some interesting concepts and works surprisingly for how simple it is. It strikes a good balance of not having too much boilerplate and having actual benefits. It feels like with most architectures, you spend 90% of your time making sure it follows the architecture rather than actually coding. I tried to make sure that this architecture allows for general coding patterns and doesn't restrict you too much. I didn't go into it, but this architecture makes testing super simple and is one of the main reasons I went with it on many of my projects.