Design Patterns in Rust – Singleton Pattern

The singleton pattern gives you the benefits of global access while maintaining control over how that global state is created, accessed, and modified. It's essentially "global variables done right" for cases where you genuinely need global state.

The singleton pattern gives you the benefits of global access while maintaining control over how that global state is created, accessed, and modified. It’s essentially “global variables done right” for cases where you genuinely need global state.

To demo this, we’ll code an example to connect to a database. This is just to make the example more real world. You can use Docker, or your own database if you have one, and know how to connect to it with Rust.

We will useOnceLock to safely initialize the Mutex<Database> once at runtime, after calling the async constructor.

OnceLock<Mutex<Database>>


Skip ahead if you know all this, it will involve some tinkering with docker compose, postgres as well, which will be brief but useful

! By default, PgPool from sqlx has a pool size of 5 connections.

For more examples, check out this site, it’s got some great examples: https://refactoring.guru/design-patterns/singleton/rust/example


version: '3.8'
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: demo123
      POSTGRES_DB: app
      POSTGRES_USER: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
docker compose up -d postgres
docker compose exec postgres psql -U postgres -d app -c "                     
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (name, email) VALUES
    ('Alice Johnson', 'alice@example.com'),
    ('Bob Smith', 'bob@example.com'),
    ('Charlie Brown', 'charlie@example.com')
ON CONFLICT (email) DO NOTHING;
"
❯ docker ps
CONTAINER ID   IMAGE           COMMAND                  CREATED          STATUS                    PORTS                                                           NAMES
6ced1b1d666d   postgres:15     "docker-entrypoint.s…"   49 minutes ago   Up 49 minutes (healthy)   0.0.0.0:5432->5432/tcp, :::5432->5432/tcp                       youtube_videos-postgres-1

Code

[package]
name = "singleton"
version = "0.1.0"
edition = "2024"

[dependencies]
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] }
tokio = { version = "1.46.1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4"] }

Analogy:

Think of OnceCell like a bank vault that:

Only allows one deposit, ever (.set)

Allows multiple withdrawals (.get)

If the vault is empty and you try to withdraw → panic!

If someone tries to deposit again → error!

use sqlx::PgPool;
use sqlx::Row;
use std::sync::OnceLock;
use tokio::sync::Mutex;

static DATABASE: OnceLock<Mutex<Database>> = OnceLock::new();

pub struct Database {
    pool: PgPool,
}

impl Database {
    pub async fn new() -> Result<Self, sqlx::Error> {
        let database_url = std::env::var("DATABASE_URL")
            .unwrap_or_else(|_| "postgresql://postgres:demo123@localhost/app".to_string());
        // POSTGRES_PASSWORD=demo123

        let pool = PgPool::connect(&database_url).await?;
        Ok(Database { pool })
    }
    
    pub fn get_pool(&self) -> &PgPool {
        &self.pool
    }
}

// Better pattern for async singleton initialization:
pub async fn initialize_database() -> Result<(), sqlx::Error> {
    let db = Database::new().await?;
    DATABASE.set(Mutex::new(db)).map_err(|_| sqlx::Error::Configuration("Database already initialized".into()))?;
    Ok(())
}

pub fn get_database_sync() -> &'static Mutex<Database> {
    DATABASE.get().expect("Database not initialized. Call initialize_database() first.")
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize the database first
    initialize_database().await?;
    
    // Now you can use the database
    let db = get_database_sync();
    let db_lock = db.lock().await;
    
    // Use the database pool
    let pool = db_lock.get_pool();
    
    // Example query
    let rows = sqlx::query("SELECT id, name, email FROM users")
        .fetch_all(pool)
        .await?;
    
    for row in rows {
        let id: i32 = row.get("id");
        let name: String = row.get("name");
        let email: Option<String> = row.get("email");
        println!("User: {} - {} - {:?}", id, name, email);
    }
    
    Ok(())
}

The key insight:

  • Mutex is for thread-safe access to data
  • If you’re not moving the database connection to different threads, you only need Mutex

When you DO need Arc:

  • Passing the database to spawned async tasks
  • Sharing between multiple threads
  • Cloning references to pass around

When you DON’T need Arc:

  • Simple single-threaded usage
  • All access happens from the same context
  • Tutorial/learning scenarios

This version is much cleaner for teaching the singleton pattern! The OnceLock<Mutex<Database>> gives you:

  1. Singleton behavior – only one instance ever created
  2. Thread safety – safe to access from multiple threads
  3. Simplicity – no unnecessary complexity

Why do we need OnceLock?

OnceLock solves the initialization problem – ensuring our database connection is created exactly once, even if multiple parts of code call get_database() simultaneously.

Here’s what would happen without OnceLock:

// BAD - Without OnceLock
static mut DATABASE: Option<Mutex<Database>> = None;

pub async fn get_database() -> &'static Mutex<Database> {
    unsafe {
        if DATABASE.is_none() {
            // PROBLEM: What if two threads reach this point at the same time?
            let db = Database::new().await.expect("Failed to connect");
            DATABASE = Some(Mutex::new(db));
        }
        DATABASE.as_ref().unwrap()
    }
}

The race condition:

  1. Thread A calls get_database(), sees DATABASE is None
  2. Thread B calls get_database(), also sees DATABASE is None
  3. Both threads try to create a database connection!
  4. We end up with two connections instead of one (breaking the singleton pattern)

What OnceLock does:

  • Guarantees the initialization closure runs exactly once
  • Handles all the thread synchronization for us
  • Subsequent calls just return the already-created instance

When you need Arc, you’d still use OnceLock for the same initialization safety reasons.

When to use the Mutex

Keep the Mutex<Database> only if:

  • You’re mutating state inside Database that isn’t thread-safe,
  • Or you want to guard certain operations (like doing one query at a time).

If not — ditch the Mutex and use OnceLock<Database> directly. It’s cleaner and faster.