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
| Layer | Responsibility | Key Code |
|---|---|---|
| FastAPI | ASGI server, lifespan management, OpenAPI docs | app.py |
| CORS Middleware | Origin validation, preflight responses | CORSMiddleware in app.py |
| Rate Limiter | Sliding-window per-IP throttling (100 req / 60s default) | singularity/middleware/ratelimit.py |
| Exception Handler | Catch unhandled exceptions, structured error responses in dev | singularity/middleware/exceptions.py |
| Router | URL path to service method dispatch | Auto-generated by Manager |
| Service | Business logic, database access, RPC calls | services/<name>/service.py |
| Database | Async SQLAlchemy sessions, connection pooling | singularity/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:
-
Manager creation -- A
Managerinstance is created with the FastAPI app and a default/apiprefix. It instantiates anAcquireDI container. -
Middleware registration --
register_middlewares()scanssingularity/middleware/for Python files containing aMiddlewareclass and adds each to the FastAPI middleware stack. Middlewares that acceptacquirein__init__receive the DI container. -
Service registration --
register_services()recursively scansservices/, skipping__base,__pycache__, and entries listed in.services_disabled. For eachservice.pyfound, it imports the module, locates the*Serviceclass, inspects whether__init__accepts anacquireparameter, and injects accordingly. -
RPC initialization --
_init_rpc()registers all services withrpc_exposed = Truein theRPCRegistryand generates the RPC spec.
Startup Phase (Asynchronous)
Once uvicorn or gunicorn starts the ASGI server, the lifespan context manager fires:
-
Publish to Redis -- The RPC spec is published to Redis with a 60-second TTL so other deployments can discover this instance's services.
-
Discover remote -- The registry queries Redis for specs published by other deployments, registering any remote services for HTTP-based RPC calls.
-
Heartbeat -- If heartbeat is enabled (default), an async task refreshes the Redis TTL every 30 seconds. Disable with
--no-heartbeatfor development or single-instance deployments.
Shutdown Phase
-
Cancel heartbeat -- The background heartbeat task is cancelled and awaited.
-
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:
| Component | Location | Discovery Rule |
|---|---|---|
| Services | services/<path>/service.py | Class name ends with Service |
| Middlewares | singularity/middleware/<name>.py | Class named Middleware |
| Tasks | tasks/executable/<name>.py | Functions decorated with @task |
| Scripts | scripts/executable/<name>.py | Classes 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.pymounts at/api/v1/payments. - HTTP methods map to method names: define
async def get(self)and you get aGETendpoint. - Custom routes use
http_exposedwith a simplemethod=pathsyntax:"get=status"maps toget_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.
Testing
Unit test services, mock RPC calls and microservices, verify webhooks, and run integration tests using Singularity's built-in testing harness.
Dependency Injection
Deep dive into the Acquire DI container, how the Manager injects dependencies into services, auto-discovery mechanics, and service name injection for RPC.