DEV Community

MSC
MSC

Posted on

Practical Clean Architecture in Typescript, Rust & Python

Goal

The goal of this article is to share starter templates in Typescript, Rust & Python for the Rest API, using Clean Architecture.

TL;DR

Ok but why?

I've been using this Clean Architecture in several projects recently, mostly in Typescript & Rust and recently tried with Python too.

I'd like to encourage software engineers to use clean code & clean architecture patterns because, among other things, it helps preventing tight coupling between infrastructure & business logic and writing code that's easier to maintain.

I've seen enough projects in my professional experience with a lack of well defined structure, lots of tight coupling between business logic & database's DAO objects, low or lack of test coverage, very hard maintenance capabilities, high complexity preventing the on-boarding of a developer on the project, etc. That tells me that there's still in 2022 a real lack of good code quality in many companies. If "it works", it's good enough right?

What does this article contain?

The exercise here is to build this Rest API, declined in Typescript, Rust & Python, with the same functional requirements and with Clean Architecture.
The tech stacks vary and the whole point of Clean Architecture is that we should be able to isolate the Business logic from the Infrastructure and change one without affecting the other.

Disclaimer; please keep in mind this is a template which is not production ready as it lacks security features, advanced testing, authentication, CI/CD, ...
However, I'm looking forward to any suggestions you might have :)

Clean architecture

The goal isn't to explain Clean Architecture, there are so many great articles out there, check out these very good tutorials:

API functional requirements

We're going to build a project for a hypothetical client who loves animals, especially dogs & cats and wants to expose an Http Rest API that provides Dogs & Cats Facts. Why not, right?

We need to use 2 sources:

  • cat facts: https://catfact.ninja
  • dog facts: knowledge acquired over the ages that should be stored in a database

(see where this going :D ?)

We have technical constraint on the API data output:

  • Cat facts should be formatted as such


  {
    fact: String,
    nb_chars: Integer
  }


Enter fullscreen mode Exit fullscreen mode
  • Dog facts should be formatted as such


  {
    fact_id: Integer,
    txt: String
  }


Enter fullscreen mode Exit fullscreen mode

The client needs to fetch all cat facts and all dog facts, but also individual dog facts and random cat facts.

That's pretty much it.

Oh... and the client isn't sure about the technology of the database, may run out of budget and change it later and will probably use another source for cat facts in the future. Alice knows Rust, Bob is keen on Typescript and Mallory loves Python, they're undecided about the language to use so we're going to build the same API 3 times, obviously, right? Here's the proposal:

  • Typescript: Fastify, axios & TypeOrm (PostgreSQL)
  • Rust: ActixWeb, reqwest & Diesel Orm (PostgreSQL)
  • Python: Fast API, requests & Peewee Orm (SQLite)

No need to worry about security, their infrastructure is so very secure, this is 2022 c'mon.

Design

Overview

The Clean Architecture pattern seems like a good idea since the business requirements are clear (that never happens) and the infrastructure which is likely to change (that happens a lot and we caught it in time).

clean architecture

Source and credit: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

Logical entities (Domain layer)

The protagonists are Cat & Dog Facts, but we should also have some configuration, a generic error, ...

These represent our most abstracted logic level that we want to manipulate throughout the entire code base. In Clean Architecture, the Dependency Rule tells us that Domain Layer objects are accessible in every Layer (black arrow below).

clean architecture cone

Source and credit: https://www.codingblocks.net/podcast/clean-architecture-make-your-architecture-scream/

These are Plain Old T(ypescript)/R(ust)/P(ython) Objects.



CatFactEntity:
  fact_txt: String
  fact_length: Integer


Enter fullscreen mode Exit fullscreen mode


DogFactEntity:
  fact_id: Integer
  fact: String


Enter fullscreen mode Exit fullscreen mode

Business Use Cases (Application layer)

Business Use Cases are NOT AFFECTED by the infrastructure choices. An algorithm that SUMS variable1 to variable2 should return the sum, regardless of where the data is coming from (PostgreSQL, Hadoop, an Excel Spreadsheet, a Wormhole, ...), right?!

Therefore there should NEVER be an import of the adapter/infrastructure layer in either the Application or Domain layer. That's where the Dependency Rule makes sense. The lower layers should not know about the higher layers (Application & Domain, the logic, can't know about the technologies choices). The opposite isn't true however, the external layer knows about the internal layer(s).

infra vs domain

Source and credit: https://pusher.com/tutorials/clean-architecture-introduction/

Since this is a Rest API, I try to associate 1 Use Case with 1 route. Obviously, in a complex application, some very generic Use Cases could be reused. Internet is full of debates on the subject, since a Use Case is tied to an action the application is doing, there should be 1 Use Case per route only... but Clean Code also tells us not to reuse code... enjoy the developer debates :)

As stated above in the functional requirements

  • GetAllCatFactsUseCase(repository_interface): fetch all cat facts
  • GetOneRandomCatFactUseCase(repository_interface): fetch one random cat fact
  • GetAllDogFactsUseCase(repository_interface): fetch all dog facts
  • GetOneDogFactByIdUseCase(dog_fact_id: Integer, repository_interface): fetch an individual dog fact

These Use Cases accept as parameters the repository_interface. That way, we don't have to know what technology is going to be used by the actual repository (cat_fact_repository_mysql or cat_fact_repository_hadoop, cat_fact_repository_http, ...). We only access the "abstract" methods defined by the interface. That's one of the mechanisms of Dependency Injection.

Interfaces (Adapter layer)

Clean architecture is derived from Hexagonal Architecture, hence the terms API (Application Programming Interface: client facing) & SPI (Service Provider Interface: the opposite side of clients, pointing to data/service providers).

hexagonal architecture

Source and credit: https://beyondxscratch.com/2017/08/19/hexagonal-architecture-the-practical-guide-for-a-clean-architecture/

Here, both the API & SPI are related to bold technological choices; tools from the Web Server or Framework, an Http library, a SQL driver, an ORM, ...

SPI

The data sources are in the functional requirements above.

Repositories are responsible for fetching the data. The connection object to the data source is injected (an Http library, a sql driver manager, ...) and used to access the data. The reason why this works regardless of the technology chosen is because we use Interfaces defined in the Application Layer.

DogFactsRepositoryAbstract defines get_dog_fact_by_id(dog_fact_id: Integer) and get_all_dog_facts. Regardless of the technology chosen to access the data source, these methods WILL BE ACCESSIBLE to the Use Cases.

The Cat API https://catfact.ninja returns cat facts in the format:



{
  "fact": "cat fact",
  "length": 8
}


Enter fullscreen mode Exit fullscreen mode

The DB (for obscure reasons) data model is



CREATE TABLE dog_facts (id INTEGER PRIMARY KEY AUTOINCREMENT, fact TEXT)


Enter fullscreen mode Exit fullscreen mode

Not quite the data formats we want to output in our API.

No problem. For that we use Mappers that are classes responsible for translating an SPI object into its Domain Layer equivalent. For example, a CatFactApiModel object coming back from the external Cat Fact API into a Domain CatFactEntity. That way, the repository (Adapter Layer) returns to the Use Case (Application Layer, below) an Entity (Domain Layer); Dependency Rule respected.

API

Simple REST:

  • GET /api/v1/cats/: all cat facts
  • GET /api/v1/cats/random: a random cat facts
  • GET /api/v1/dogs/: all dog facts
  • GET /api/v1/dogs/{id}: a dog fact with id {id}

These are defined or handled by the Controllers. Their responsibility is to control the data coming in & out of the API. Anything related to logic should be done in a Use Case. This is where the Infrastructure level configuration or repository implementation has to be injected into the Use Case.

Environments (Infrastructure layer)

Based on the environment variable ENV, it'll get the app setting from the .env.<ENV> file (.env.dev, .env.test, ...).

This is where all the overall server configuration is found; security plugins, data validations, ports configurations, ... This is as related to the infrastructure technology choice as it gets.

Overview

So that gives us this example of data flow through all the layers of the Clean Architecture.

Life Cycle

Testing

Doing a few simple tests always goes a long way. This isn't a test related article so I won't get into details here. All 3 implementations have the same tests.

Unit tests

The focus here is on the Use Cases, because they represent the business Use Cases that NEVER change, even if the infrastructure changes.
Obviously, other classes/files could have been unit tested, such as mappers, repositories but this is not the point of this exercise:

  • tests for GetAllCatFactsUseCase:

    • should raise exception when unexpected repo exception
    • should raise exception when expected repo exception
    • should return empty list
    • should return list
  • tests for GetAllDogFactsUseCase:

    • should raise exception when unexpected repo exception
    • should raise exception when expected repo exception
    • should return empty list
    • should return list
  • tests for GetOneDogFactByIdUseCase:

    • should raise exception when unexpected repo exception
    • should raise exception when expected repo exception
    • should return one result
  • tests for GetOneRandomCatFactUseCase:

    • should raise exception when unexpected repo exception
    • should raise exception when expected repo exception
    • should return one result

Integration tests

The focus here is on the routes and their valid usage, because they represent the data that comes back in the Presenter format, regardless of the infrastructure or SPIs (3rd party API, database, ...). Obviously, other cases, such as exceptions, security, ... could have been tested but this is not the point of this exercise:

  • file: test_cat_facts
    • should return multiple results for /api/v1/cats/facts
    • should return one results only for /api/v1/cats/random
  • file: test_dog_facts
    • should return multiple results for /api/v1/dogs/facts
    • should return one results only for /api/v1/dogs/{id}

Implementation

Here, I'm listing the characteristics of each declination of the API into each language.

Characteristics

Typescript

github repo: https://github.com/MSC29/clean-architecture-typescript

Web Server (Infrastructure layer):

SPI (Adapter layer):

  • Http lib: axios
  • Db: TypeORM + PostgreSQL

Rust

github repo: https://github.com/MSC29/clean-architecture-rust

Web Server (Infrastructure layer):

SPI (Adapter layer):

  • Http lib: reqwest
  • Db: Diesel Orm + PostgreSQL

Python

github repo: https://github.com/MSC29/clean-architecture-python

Web Server (Infrastructure layer):

SPI (Adapter layer):

  • Http lib: requests
  • Db: Peewee Orm + SQLite

Similarities & differences between all 3 projects

Pretty much everything is the same between all 3 projects (the folder structure, routes names & schema, tests, variable names, error texts, ...) to make it easy to compare & understand. There are obvious differences when it comes to the case, imports, tests names, ... which are down to the language & frameworks used.

Some other differences exist, where I wanted to try something differently such as the helper methods for tests, the instantiation of the app or the mock api server for the Http SPI. But that does not change the fact the tests are the same and the API interfaces are the same and business Use Cases are the same :)

Changing the Infrastructure

The SPI (Adapter layer) are "easily" changeable because they're not related to the Server/Framework used nor to the Business Use Cases in the Application & Domain layers. In the repositories, there's no import of the lib used to query the Database or Http endpoints. This, however, could be further improved by implementing all methods we'd need to use inside the DbConnection and HttpConnection (get, post, find, etc.).

Changing Http lib or database should only affect the SPI repository and the Connection injected. In a more complex project, you may have very specific SQL (for Oracle for example), which might not port well to another Database (SqlServer for example). There will be changes to make, but at least your business Use Cases won't budge.

In my implementation, I'm not completely separating the Infrastructure from the Adapter layer when the Web Server is concerned. The routes definitions and some injectors from the Adapter layer are directly related to the Server used... That being said, the move to another framework affects only 2 layers in very well defined places and would not impact the SPI. If you were to switch to Flask instead of FastAPI in Python, the Infrastructure & API controllers/routes would change a bit, but not the Http or Db repositories, nor the Use Cases.

Other benefits of the Clean Architecture

On top of what's stated above, this framework helps a lot with the overall structure of the code. It's very obvious to determine where code should go: pieces of Business logic in the Use Cases, security configuration in the Infrastructure's configuration, pagination of external Apis in the Adapter SPI repositories, etc.

Unit testing Use Cases becomes obvious & simple. Simply inject the repository(ies) needed as Stubs to mock their behaviors and get a good coverage & very detailed understanding of your Business logic. That's a huge benefit in complex applications. You could even work hand to hand with your Product Owner and provide them with your BDD test execution list as Acceptance Criteria, but I'm getting sidetracked...

Conclusion

I hope this helps starting off with a Clean Architecture project; whether it were in Typescript, Rust or Python.

I've got more examples and things that I've done again differently in other projects. This is a very simple project where the Use Cases are pretty slim and there aren't POST/PUT/DELETE requests. But nothing changes, the same logic applies.

Remember, the whole point is to separate the code in an intelligible way and make maintenance & testing easy.

In a company you'll be asked to maintain an application, make changes & never create downtime for the clients. You'll also be asked to change a framework or a database for budget reasons and onboard new joiners to the project.
In my own experience, doing all of the above has been a lot simpler with Clean Architecture, despite the initial learning curve.

I hope you enjoyed that, I certainly did, writing the same API in 3 different languages has been fun! :)

I'd love to hear suggestions, feel free to get in touch.

Top comments (3)

Collapse
 
zo0m profile image
Alex Ted

Lord, finally! From a bunch of materials that I searched on the Internet, I finally found something worthwhile! Thanks for the article and for the cool app template.

P.S.: In my opinion, this and this templates is also worthy of attention. I canโ€™t say that they completely satisfies me, but I borrowed some ideas from there.
I hope someday I will see a new template from you, which will use axum and sqlx, or at least diesel-async.

Collapse
 
nitaicharan profile image
Nitai Charan • Edited

Man, thank you for share it. So far it was the best article I've read about clean architecture. Btw, do you have others articles? Do you publish in another platforms?

Collapse
 
msc29 profile image
MSC

Thanks a lot! No I haven't published anything else yet. Thanks for your encouraging comment, I'll consider it ;)