Singularity
Architecture

Architecture Overview

Understand Singularity's modular monolith design, request lifecycle, application lifespan events, and core design principles.

Singularity is a modular monolith -- a single deployable unit that enforces strict module boundaries internally. This page explains the philosophy behind that choice, walks through the full system architecture, and details how a request travels from client to database and back.

Modular Monolith Philosophy

Microservices solve organizational scaling problems (independent teams, independent deployments) but introduce significant operational complexity: distributed tracing, network partitions, data consistency, and deployment orchestration. For most projects -- especially early-stage ones -- that complexity is not justified.

Singularity takes a different approach:

Single Deployment

One process, one deploy target, one log stream. No service mesh, no API gateway, no orchestration layer needed to get started.

Strict Module Boundaries

Services are isolated directories with their own models, schemas, and routes. Cross-service communication goes through the RPC layer -- never via direct imports.

Microservice-Ready

When the time comes to extract a service into its own deployment, the RPC system already supports remote HTTP calls via Redis-backed discovery. The migration path is built in.

Convention Over Configuration

Drop a service.py file in the right directory and it is auto-discovered, injected with dependencies, and mounted on the API. No registration boilerplate.

The modular monolith is not a compromise -- it is a deliberate architectural choice. You get the isolation benefits of microservices without the operational tax, and a clear migration path when you outgrow a single process.


Full Architecture Diagram

The following diagram shows every major component in the system and how they connect. External clients and webhook providers enter through the FastAPI layer. The Manager orchestrates service registration and dependency injection. The RPC Registry handles both local in-process calls and remote HTTP discovery via Redis. Background tasks flow through Redis into Celery workers.


Request Lifecycle

Every HTTP request follows the same path through the system. Middleware layers execute in registration order before the request reaches the router. The router dispatches to the correct service method, which performs business logic -- typically involving database access -- and returns a response that travels back through the middleware stack.

WebSocket connections follow a similar path but upgrade to a persistent bidirectional channel after the initial handshake. Webhook requests are POST endpoints handled by BaseWebhook subclasses that verify signatures before dispatching to event handlers.

What Happens at Each Layer

LayerResponsibilityKey Code
FastAPIASGI server, lifespan management, OpenAPI docsapp.py
CORS MiddlewareOrigin validation, preflight responsesCORSMiddleware in app.py
Rate LimiterSliding-window per-IP throttling (100 req / 60s default)singularity/middleware/ratelimit.py
Exception HandlerCatch unhandled exceptions, structured error responses in devsingularity/middleware/exceptions.py
RouterURL path to service method dispatchAuto-generated by Manager
ServiceBusiness logic, database access, RPC callsservices/<name>/service.py
DatabaseAsync SQLAlchemy sessions, connection poolingsingularity/db/postgres.py

Application Lifespan

The application goes through a precise sequence of steps from process start to serving requests, and an orderly teardown on shutdown. Understanding this flow is critical for knowing when services, RPC, and the heartbeat become available.

Startup Phase (Synchronous)

The synchronous portion runs when the module is first imported, before the ASGI server starts accepting connections:

  1. Manager creation -- A Manager instance is created with the FastAPI app and a default /api prefix. It instantiates an Acquire DI container.

  2. Middleware registration -- register_middlewares() scans singularity/middleware/ for Python files containing a Middleware class and adds each to the FastAPI middleware stack. Middlewares that accept acquire in __init__ receive the DI container.

  3. Service registration -- register_services() recursively scans services/, skipping __base, __pycache__, and entries listed in .services_disabled. For each service.py found, it imports the module, locates the *Service class, inspects whether __init__ accepts an acquire parameter, and injects accordingly.

  4. RPC initialization -- _init_rpc() registers all services with rpc_exposed = True in the RPCRegistry and generates the RPC spec.

Startup Phase (Asynchronous)

Once uvicorn or gunicorn starts the ASGI server, the lifespan context manager fires:

  1. Publish to Redis -- The RPC spec is published to Redis with a 60-second TTL so other deployments can discover this instance's services.

  2. Discover remote -- The registry queries Redis for specs published by other deployments, registering any remote services for HTTP-based RPC calls.

  3. Heartbeat -- If heartbeat is enabled (default), an async task refreshes the Redis TTL every 30 seconds. Disable with --no-heartbeat for development or single-instance deployments.

Shutdown Phase

  1. Cancel heartbeat -- The background heartbeat task is cancelled and awaited.

  2. Remove from Redis -- The RPC spec keys are deleted from Redis so other deployments stop routing traffic to this instance.


Design Principles

Singularity is built on three core principles that inform every design decision in the framework.

Auto-Discovery

Services, middlewares, tasks, and scripts are all discovered automatically by scanning the filesystem. There are no registration arrays to maintain, no import lists to keep in sync. The conventions are:

ComponentLocationDiscovery Rule
Servicesservices/<path>/service.pyClass name ends with Service
Middlewaressingularity/middleware/<name>.pyClass named Middleware
Taskstasks/executable/<name>.pyFunctions decorated with @task
Scriptsscripts/executable/<name>.pyClasses inheriting from BaseScript

Convention Over Configuration

Singularity minimizes configuration by defining sensible defaults and consistent patterns:

  • URL routing is derived from the directory path: services/v1/payments/service.py mounts at /api/v1/payments.
  • HTTP methods map to method names: define async def get(self) and you get a GET endpoint.
  • Custom routes use http_exposed with a simple method=path syntax: "get=status" maps to get_status().
  • Service names for RPC are the directory name, injected automatically as _service_name.

Dependency Injection

The Acquire container holds all shared resources -- database sessions, settings, the task runner, the WebSocket manager, caches, and utilities. The Manager inspects each service's __init__ signature: if it accepts acquire, it receives the container. Services that do not need shared resources can omit the parameter entirely.

# Service WITH dependency injection
class PaymentsService(Service):
    def __init__(self, acquire):
        super().__init__(acquire)
        self.db = acquire.db_session
        self.settings = acquire.settings

# Service WITHOUT dependency injection
class HealthService(Service):
    async def get(self):
        return {"status": "healthy"}

The Acquire container is a singleton created once by the Manager. All services share the same instance. This is by design -- it ensures consistent access to database sessions, settings, and other resources across the entire application.

Both patterns are valid. The Manager handles both transparently, keeping service code focused on business logic rather than infrastructure wiring.