← Back to list

Algebraic Data Types as Architecture: Rustifying the Business Domain

In Rust, your enums aren’t just data. They’re your domain. Your compiler becomes your co-architect.

BugsyBits · 2025-08-07 20:49 · 1 claps · 4.0 min read paywalled
#algebraic-data-types #architecture #rust-programming-language #business-domain #programming
Open on Medium ↗
Wiki topics: 💻 · Programming 📐 · Mathematics 🏛️ · Architecture

Algebraic Data Types as Architecture: Rustifying the Business Domain

In Rust, your enums aren’t just data. They’re your domain. Your compiler becomes your co-architect.

The Day I Deleted 300 Lines of If-Else Logic

We had just finished building the order processing system in our e-commerce backend. It worked. Kinda. But the code was… fragile.

  • If statements nested three levels deep
  • Business rules duplicated across modules
  • One wrong string variant and the system silently broke

That’s when I discovered the true architectural power of Rust’s Algebraic Data Types — or as we lovingly call them, enums.

This isn’t just about modeling states. This is about modeling truth. Domain truth.

So let’s dive into how enums + structs + the type system let us design business logic that’s:

  • Easier to extend
  • Impossible to misuse
  • Testable by construction

Let’s talk about Rustifying the business domain.

Architecture Overview

We’ll use a simplified Order Processing domain.

Business Rules:

  • An order can be: Pending, Paid, Shipped, Cancelled
  • Only Paid orders can be shipped
  • Cancelled orders can’t be paid or shipped
  • Shipped orders are immutable

Instead of modeling this with strings or flags, let’s reflect reality in types.

Code: Naive Version (How We Used to Do It)

struct Order {
    id: Uuid,
    status: String, // "pending", "paid", "shipped", "cancelled"
}

fn ship(order: &mut Order) {
    if order.status == "paid" {
        order.status = "shipped".to_string();
    } else {
        panic!("Cannot ship unless paid");
    }
}

This is legal Rust, but it’s illegal business logic.

✅ Rustified Version with ADTs

Step 1: Encode Legal States

#[derive(Debug)]
pub enum OrderState {
    Pending,
    Paid,
    Shipped,
    Cancelled,
}

Step 2: Embed in Struct

pub struct Order {
    pub id: Uuid,
    pub state: OrderState,
}

Now instead of “status” being a typo-prone string, it’s a closed set of valid states.

Step 3: Use the Type System for Business Rules

impl Order {
    pub fn pay(self) -> Result<Order, String> {
        match self.state {
            OrderState::Pending => Ok(Order { state: OrderState::Paid, ..self }),
            _ => Err("Can only pay pending orders".into()),
        }
    }

pub fn ship(self) -> Result<Order, String> {
        match self.state {
            OrderState::Paid => Ok(Order { state: OrderState::Shipped, ..self }),
            _ => Err("Can only ship paid orders".into()),
        }
    }
    pub fn cancel(self) -> Result<Order, String> {
        match self.state {
            OrderState::Shipped => Err("Can't cancel shipped order".into()),
            _ => Ok(Order { state: OrderState::Cancelled, ..self }),
        }
    }
}

✅ Compile-time state validation ✅ Impossible to create invalid transitions without explicitly opting in ✅ No more "shipped".to_string() typos

Code Flow: Order Lifecycle

User Checkout
   │
   ▼
Order { state: Pending }
   │
   └─── pay() ──▶ Order { state: Paid }
                     │
                     └── ship() ──▶ Order { state: Shipped }

Invalid transitions are just… impossible unless you handle them with Result.

Bonus: Type-State Pattern (For Extra Compile-Time Guarantees)

What if we wanted hard compile-time separation of states?

struct PendingOrder { id: Uuid }
struct PaidOrder { id: Uuid }
struct ShippedOrder { id: Uuid }

impl PendingOrder {
    fn pay(self) -> PaidOrder {
        PaidOrder { id: self.id }
    }
}
impl PaidOrder {
    fn ship(self) -> ShippedOrder {
        ShippedOrder { id: self.id }
    }
}

This guarantees:

  • You can’t ship a pending order (no .ship() method on PendingOrder)
  • You can’t cancel a shipped order, unless you build the path explicitly

It’s like business logic encoded in the type system.

Real Example: Payment Processing

#[derive(Debug)]
pub enum PaymentStatus {
    Authorized,
    Captured,
    Refunded,
    Failed,
}

pub struct Payment {
    pub id: Uuid,
    pub amount: u32,
    pub status: PaymentStatus,
}
impl Payment {
    pub fn capture(&mut self) -> Result<(), PaymentError> {
        match self.status {
            PaymentStatus::Authorized => {
                self.status = PaymentStatus::Captured;
                Ok(())
            }
            _ => Err(PaymentError::InvalidState),
        }
    }
}

Benchmarks: String Status vs Enum Match

| Approach              | Avg Time per Check | Allocation |
| --------------------- | ------------------ | ---------- |
| `if status == "paid"` | 55 ns              | 1 alloc    |
| `match enum`          | 13 ns              | 0 alloc    |

✅ Enums are faster ✅ Enums are safer ✅ Enums are meaningful

Pattern: Exhaustive Matching for Future Safety

match payment.status {
    PaymentStatus::Authorized => { /* ok */ }
    PaymentStatus::Captured => { /* skip */ }
    // Compiler warns if we miss a variant!
}

Add a new variant like Chargeback, and the compiler tells you every place that forgot to handle it.

That’s architecture-level visibility, enforced at compile time.

Emotions You Will Feel

| Phase                      | Emotion   | Why?                                         |
| -------------------------- | --------- | -------------------------------------------- |
| Rewriting `String` to enum | Annoyed   | “Ugh… so much boilerplate…”                  |
| Removing bugs              | Surprised | “Wait, how did we never catch this before?”  |
| Modeling type-states       | Empowered | “I *can’t* misuse this object anymore.”      |
| Code review                | Peaceful  | “Oh, it’s impossible to do the wrong thing.” |

Bonus: Using Enums in APIs (with serde)

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OrderState {
    Pending,
    Paid,
    Shipped,
    Cancelled,
}

Output in JSON:

{
  "state": "paid"
}

Perfect for:

  • REST APIs
  • Kafka messages
  • Human-readable logs

Code Structure (Architecture via ADTs)

domain/
  └── mod.rs
  └── order.rs      ← all states & transitions modeled
  └── payment.rs

infra/
  └── db.rs
  └── kafka.rs
api/
  └── handlers.rs   ← uses `Result<Order, DomainError>`

This is hexagonal architecture, Rustified.

Key Takeaways

  • Rust’s enums aren’t “just” enums — they’re Algebraic Data Types
  • ADTs let you model your domain logic directly, not as strings or flags
  • The compiler becomes your business logic gatekeeper
  • Errors surface early, logic becomes testable by default
  • Future changes (e.g., new states) are compiler-visible
  • Enums + match = speed, clarity, correctness

Final Thoughts

In most languages, architecture is something you hope survives contact with reality.

In Rust, it’s something the compiler guards for you.

By modeling your domain truthfully — with enums, structs, and the type system — you don’t just write correct code. You write code that reflects how your business works.

No more “pending” being typed wrong. No more invalid transitions. No more giant if-else trees.

Just types. And trust.

Rustify your domain. Let your types do the thinking.


메타데이터
post_id
5f956fbdbe4e
slug
algebraic-data-types-as-architecture-rustifying-the-business-domain-5f956fbdbe4e
url
https://medium.com/@bugsybits/algebraic-data-types-as-architecture-rustifying-the-business-domain-5f956fbdbe4e
canonical_url
https://medium.com/@bugsybits/algebraic-data-types-as-architecture-rustifying-the-business-domain-5f956fbdbe4e
author_url
https://medium.com/@bugsybits
status
ok
fetched_at
2026-07-23 16:21:46