» Rust: Build a REST API with Rocket » 2. Development » 2.5 4 Layers Architecture

4 Layers Architecture

At this point, your code is running as expected. But, if you take a closer look, it's not "clean" enough.

Not "clean"

Here are some of the not-clean points of the current code:

// Initialize a database instance
lazy_static::lazy_static! {
    static ref DB: Mutex<Database> = Mutex::new(Database::new().unwrap());
}
  • It's a bad idea to explicitly use a global variable to hold the database instance and use it all around.

In Rust, a static item is a value which is valid for the entire duration of your program (a 'static lifetime).

pub fn new() -> SqliteResult<Self> {
    let conn = Connection::open("test.db")?;
    conn.execute(
        "CREATE TABLE IF NOT EXISTS books (
            id INTEGER PRIMARY KEY,
            title TEXT NOT NULL,
            author TEXT NOT NULL,
            published_at TEXT NOT NULL,
            description TEXT NOT NULL,
            isbn TEXT NOT NULL,
            total_pages INTEGER NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )",
        [],
    )?;
    Ok(Database { conn })
}
  • It's not a good practice to initialize a database in the business code. Database stuff is "infrastructure", but not "business". They're different concerns, and you should put them in different places.
#[get("/books")]
fn get_books() -> Result<Json<Vec<model::Book>>, status::Custom<Json<ErrorResponse>>> {
    let db = DB.lock().unwrap();
    match db.get_books() {
        Ok(books) => Ok(Json(books)),
        Err(err) => Err(status::Custom(
            Status::InternalServerError,
            Json(ErrorResponse {
                error: err.to_string(),
            }),
        )),
    }
}
  • It's not recommended to directly rely on a third party database instance in your business code. db is an instance from Rusqlite crate, which is a third party library. The cost would be huge if you decide to switch to a new ORM framework in the future.

Separation of Concerns

Clean Arch From https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

The Clean Architecture is a software architectural pattern introduced by Robert C. Martin, in his book "Clean Architecture: A Craftsman's Guide to Software Structure and Design." It emphasizes the separation of concerns within a software system, with the primary goal of making the system more maintainable, scalable, and testable over its lifecycle.

4 Layers Architecture varies somewhat in the details, but it's similar to the Clean Architecture. They all have the same objective, which is the separation of concerns. They all achieve this separation by dividing the software into layers.

4 layers

  1. Adapter Layer: Responsible for routing and adaptation of frameworks, protocols, and front-end displays (web, mobile, and etc).

  2. Application Layer: Primarily responsible for obtaining input, assembling context, parameter validation, invoking domain layer for business processing. The layer is open-ended, it's okay for the application layer to bypass the domain layer and directly access the infrastructure layer.

  3. Domain Layer: Encapsulates core business logic and provides business entities and business logic operations to the Application layer through domain services and domain entities' methods. The domain is the core of the application and does not depend on any other layers.

  4. Infrastructure Layer: Primarily handles technical details such as CRUD operations on databases, search engines, file systems, RPC for distributed services, etc. External dependencies need to be translated through gateways here before they can be used by the Application and Domain layers above.

Refactoring

Let's do the code refactoring with 4 layers architecture.

The new folder structure looks like this:

projects/lr_rest_books_rust
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── src
│   ├── adapter
│   │   ├── mod.rs
│   │   └── router.rs
│   ├── application
│   │   ├── executor
│   │   │   ├── book_operator.rs
│   │   │   └── mod.rs
│   │   ├── mod.rs
│   │   └── wire_helper.rs
│   ├── domain
│   │   ├── gateway
│   │   │   ├── book_manager.rs
│   │   │   └── mod.rs
│   │   ├── mod.rs
│   │   └── model
│   │       ├── book.rs
│   │       └── mod.rs
│   ├── infrastructure
│   │   ├── config
│   │   │   └── mod.rs
│   │   ├── database
│   │   │   ├── mod.rs
│   │   │   └── sqlite.rs
│   │   └── mod.rs
│   └── main.rs
└── test.db

Move model.rs into domain/model/book.rs:

Note: This tutorial ignores the src/ prefix in paths for convenience.

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Book {
    pub id: u32,
    pub title: String,
    pub author: String,
    pub published_at: String,
    pub description: String,
    pub isbn: String,
    pub total_pages: u32,
    pub created_at: String,
    pub updated_at: String,
}

Create a sibling file mod.rs to re-export symbols:

mod book;

pub use book::Book;

domain folder is where all your core business rules reside. Non-business code stay away from this folder.

Create domain/gateway/book_manager.rs:

use std::error::Error;

use crate::domain::model;

pub trait BookManager: Send + Sync {
    fn create_book(&self, b: &model::Book) -> Result<u32, Box<dyn Error>>;
    fn update_book(&self, id: u32, b: &model::Book) -> Result<(), Box<dyn Error>>;
    fn delete_book(&self, id: u32) -> Result<(), Box<dyn Error>>;
    fn get_book(&self, id: u32) -> Result<Option<model::Book>, Box<dyn Error>>;
    fn get_books(&self) -> Result<Vec<model::Book>, Box<dyn Error>>;
}

The BookManager trait defines the business capabilities of the domain entity Book. This is just a trait; the implementations are done in the infrastructure layer. Additionally, these methods allow the application layer to utilize them for business logic.

Create infrastructrue/database/sqlite.rs:

use std::error::Error;
use std::sync::Mutex;

use chrono::Utc;
use rusqlite::{params, Connection, Result as RusqliteResult};

use crate::domain::gateway::BookManager;
use crate::domain::model;

pub struct SQLitePersistence {
    conn: Mutex<Connection>,
}

impl SQLitePersistence {
    pub fn new(file_name: &str) -> RusqliteResult<Self> {
        let conn = Connection::open(file_name)?;
        Ok(SQLitePersistence {
            conn: Mutex::new(conn),
        })
    }
}

impl BookManager for SQLitePersistence {
    fn create_book(&self, b: &model::Book) -> Result<u32, Box<dyn Error>> {
        let conn = self.conn.lock().unwrap();
        conn.execute(
            "INSERT INTO books (title, author, published_at, description, isbn, total_pages)
             VALUES (?, ?, ?, ?, ?, ?)",
            params![
                b.title,
                b.author,
                b.published_at,
                b.description,
                b.isbn,
                b.total_pages,
            ],
        )?;
        Ok(conn.last_insert_rowid() as u32)
    }

    fn update_book(&self, id: u32, b: &model::Book) -> Result<(), Box<dyn Error>> {
        let conn = self.conn.lock().unwrap();
        conn.execute(
            "UPDATE books SET title = ?, author = ?, published_at = ?, description = ?, isbn = ?, total_pages = ?, updated_at = ?
             WHERE id = ?",
            params![
                b.title,
                b.author,
                b.published_at,
                b.description,
                b.isbn,
                b.total_pages,
                Utc::now().to_rfc3339(),
                id,
            ],
        )?;
        Ok(())
    }

    fn delete_book(&self, id: u32) -> Result<(), Box<dyn Error>> {
        let conn = self.conn.lock().unwrap();
        conn.execute("DELETE FROM books WHERE id = ?1", params![id])?;
        Ok(())
    }

    fn get_book(&self, id: u32) -> Result<Option<model::Book>, Box<dyn Error>> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn.prepare("SELECT * FROM books WHERE id = ?1")?;
        let book_iter = stmt.query_map(params![id], |row| {
            Ok(model::Book {
                id: row.get(0)?,
                title: row.get(1)?,
                author: row.get(2)?,
                published_at: row.get(3)?,
                description: row.get(4)?,
                isbn: row.get(5)?,
                total_pages: row.get(6)?,
                created_at: row.get(7)?,
                updated_at: row.get(8)?,
            })
        })?;

        for result in book_iter {
            return Ok(Some(result?));
        }
        Ok(None)
    }

    fn get_books(&self) -> Result<Vec<model::Book>, Box<dyn Error>> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn.prepare("SELECT * FROM books")?;
        let book_iter = stmt.query_map([], |row| {
            Ok(model::Book {
                id: row.get(0)?,
                title: row.get(1)?,
                author: row.get(2)?,
                published_at: row.get(3)?,
                description: row.get(4)?,
                isbn: row.get(5)?,
                total_pages: row.get(6)?,
                created_at: row.get(7)?,
                updated_at: row.get(8)?,
            })
        })?;

        let mut books = Vec::new();
        for result in book_iter {
            books.push(result?);
        }
        Ok(books)
    }
}

As you see here, struct SQLitePersistence implements all the methods declared in the trait BookManager.

Create infrastructrue/config/mod.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
    pub app: ApplicationConfig,
    pub db: DBConfig,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct DBConfig {
    pub file_name: String,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ApplicationConfig {
    pub port: i32,
}

The Config structure pulls out some hard-coded items in the code, which is a good practice in general.

Create application/executor/book_operator.rs:

use std::sync::Arc;

use crate::domain::gateway;
use crate::domain::model;

pub struct BookOperator {
    book_manager: Arc<dyn gateway::BookManager>,
}

impl BookOperator {
    pub fn new(b: Arc<dyn gateway::BookManager>) -> Self {
        BookOperator { book_manager: b }
    }

    pub fn create_book(&self, b: model::Book) -> Result<model::Book, Box<dyn std::error::Error>> {
        let id = self.book_manager.create_book(&b)?;
        let mut book = b;
        book.id = id;
        Ok(book)
    }

    pub fn get_book(&self, id: u32) -> Result<Option<model::Book>, Box<dyn std::error::Error>> {
        self.book_manager.get_book(id)
    }

    pub fn get_books(&self) -> Result<Vec<model::Book>, Box<dyn std::error::Error>> {
        self.book_manager.get_books()
    }

    pub fn update_book(
        &self,
        id: u32,
        b: model::Book,
    ) -> Result<model::Book, Box<dyn std::error::Error>> {
        self.book_manager.update_book(id, &b)?;
        Ok(b)
    }

    pub fn delete_book(&self, id: u32) -> Result<(), Box<dyn std::error::Error>> {
        self.book_manager.delete_book(id)
    }
}

The application layer encapsulates everything and provides interfaces for the topmost adapter layer.

Create application/wire_helper.rs:

use std::sync::Arc;

use crate::domain::gateway;
use crate::infrastructure::database;
use crate::infrastructure::Config;

pub struct WireHelper {
    persistence: Arc<database::SQLitePersistence>,
}

impl WireHelper {
    pub fn new(c: &Config) -> Result<Self, Box<dyn std::error::Error>> {
        let persistence = Arc::new(database::SQLitePersistence::new(&c.db.file_name)?);
        Ok(WireHelper { persistence })
    }

    pub fn book_manager(&self) -> Arc<dyn gateway::BookManager> {
        Arc::clone(&self.persistence) as Arc<dyn gateway::BookManager>
    }
}

WireHelper helps with DI (dependency injection). If you want advanced DI support, consider using 3rd party crates like shaku.

With all these preparation work, finally the router can do its job "cleanly". It doesn't know which database is used under the hood. Concerns are separated now.

Create adapter/router.rs:

use rocket::http::Status;
use rocket::response::{content, status};
use rocket::serde::json::Json;

use crate::application;
use crate::application::executor;
use crate::domain::model;

pub struct RestHandler {
    book_operator: executor::BookOperator,
}

#[derive(serde::Serialize)]
pub struct ErrorResponse {
    error: String,
}

// Define a health endpoint handler, use `/health` or `/`
#[get("/")]
pub fn health_check() -> content::RawJson<&'static str> {
    // Return a simple response indicating the server is healthy
    content::RawJson("{\"status\":\"ok\"}")
}

#[get("/books")]
pub fn get_books(
    rest_handler: &rocket::State<RestHandler>,
) -> Result<Json<Vec<model::Book>>, status::Custom<Json<ErrorResponse>>> {
    match rest_handler.book_operator.get_books() {
        Ok(books) => Ok(Json(books)),
        Err(err) => Err(status::Custom(
            Status::InternalServerError,
            Json(ErrorResponse {
                error: err.to_string(),
            }),
        )),
    }
}

#[get("/books/<id>")]
pub fn get_book(
    rest_handler: &rocket::State<RestHandler>,
    id: u32,
) -> Result<Json<model::Book>, status::Custom<Json<ErrorResponse>>> {
    match rest_handler.book_operator.get_book(id) {
        Ok(book) => match book {
            Some(b) => Ok(Json(b)),
            None => Err(status::Custom(
                Status::NotFound,
                Json(ErrorResponse {
                    error: format!("book {id} not found"),
                }),
            )),
        },
        Err(err) => Err(status::Custom(
            Status::InternalServerError,
            Json(ErrorResponse {
                error: err.to_string(),
            }),
        )),
    }
}

#[post("/books", format = "json", data = "<book>")]
pub fn create_book(
    rest_handler: &rocket::State<RestHandler>,
    book: Json<model::Book>,
) -> Result<Json<model::Book>, status::Custom<Json<ErrorResponse>>> {
    match rest_handler.book_operator.create_book(book.into_inner()) {
        Ok(b) => Ok(Json(b)),
        Err(err) => Err(status::Custom(
            Status::InternalServerError,
            Json(ErrorResponse {
                error: err.to_string(),
            }),
        )),
    }
}

#[put("/books/<id>", format = "json", data = "<book>")]
pub fn update_book(
    rest_handler: &rocket::State<RestHandler>,
    id: u32,
    book: Json<model::Book>,
) -> Result<Json<model::Book>, status::Custom<Json<ErrorResponse>>> {
    match rest_handler
        .book_operator
        .update_book(id, book.into_inner())
    {
        Ok(b) => Ok(Json(b)),
        Err(err) => Err(status::Custom(
            Status::InternalServerError,
            Json(ErrorResponse {
                error: err.to_string(),
            }),
        )),
    }
}

#[delete("/books/<id>")]
pub fn delete_book(
    rest_handler: &rocket::State<RestHandler>,
    id: u32,
) -> Result<status::NoContent, status::Custom<Json<ErrorResponse>>> {
    match rest_handler.book_operator.delete_book(id) {
        Ok(_) => Ok(status::NoContent),
        Err(err) => Err(status::Custom(
            Status::InternalServerError,
            Json(ErrorResponse {
                error: err.to_string(),
            }),
        )),
    }
}

pub fn make_router(wire_helper: &application::WireHelper) -> RestHandler {
    RestHandler {
        book_operator: executor::BookOperator::new(wire_helper.book_manager()),
    }
}

Replace main.rs with the following content to make it cleaner:

#[macro_use]
extern crate rocket;

mod adapter;
mod application;
mod domain;
mod infrastructure;
use crate::adapter::router::*;
use crate::infrastructure::{ApplicationConfig, Config, DBConfig};

#[launch]
fn rocket() -> _ {
    let c = Config {
        app: ApplicationConfig { port: 8000 },
        db: DBConfig {
            file_name: "test.db".to_string(),
        },
    };
    let wire_helper = application::WireHelper::new(&c).expect("Failed to create WireHelper");
    let r = adapter::make_router(&wire_helper);
    rocket::build().manage(r).mount(
        "/",
        routes![
            health_check,
            get_books,
            get_book,
            create_book,
            update_book,
            delete_book
        ],
    )
}

Refactoring is achieved. 🎉Great!

PrevNext