Why the name "Lazy Circus"? Managing multiple side effects in Haskell (databases, external APIs, logging, async execution) can often feel like a chaotic juggling act—a veritable "circus" of monad transformers and complex type constraints.
The typical workflow of an API service is straightforward:
- Accept a request
- Execute business logic
- Return a response
Libraries like servant handle steps 1 and 3 perfectly. However, the core value of your application undeniably lives in step 2. A robust business logic layer needs to handle multiple concerns: state computations, database transactions, 3rd-party API integrations, structured logging, and graceful error recovery.
Balancing these concerns often leads to messy codebases. As developers, we actually want:
- A simple mental model to reason about the program easily.
- Safe, error-resistant abstractions where making the right choice is easier than making the wrong one.
- Painless testability to verify complex logic without spinning up real infrastructure.
- Solid performance to minimize infrastructure costs.
Lazy Circus was built to enforce exactly these guarantees:
- Domain-Driven Effect Isolation: Effects are grouped into standalone DSLs (Domain Languages). When you write database logic, you don't have to worry about accidentally triggering an HTTP request.
- Regulated Interactions: The interaction between different effect languages is strictly regulated. For instance, the type system prevents you from making a long-running external API call in the middle of a database transaction.
- Out-of-the-box Testability: Everything is easily testable using mock interpreters (
Performers). You can run your production business scenarios using a test environment, inspect the exact sequence of triggered effects, and assert behavior without real side-effects. - Proven Performance: Built for real-world workloads. The application this library was originally developed for (a Telegram bot with heavy DB and API manipulations) comfortably handles ~4500 RPS in a Docker container (1 CPU, 512MB RAM) on a standard 2021 laptop.
Historical note: Lazy Circus was created in early 2025. It was originally developed as the foundation for a real-world production application and later open-sourced as a standalone library. Its design is heavily inspired by Alexander Granin's book Functional Design and Architecture.
Lazy Circus ships with a rich set of built-in capabilities ready for production use:
- Orchestration Framework: A robust control layer (
ScenarioProgram) for executing sub-scenarios, spawning asynchronous background tasks, managing environment state, and handling errors gracefully. You can orchestrate the built-in effects or plug in your own. - Database Effect DSL (via
beam): A comprehensive database language supporting both typed CRUD operations and raw queries. Advanced features like separate Read-Only connections, Row-Level Security (RLS), and transaction management are supported out-of-the-box. - Telegram Bot Integration: A ready-to-use effect for sending messages and reactions. It natively supports running multiple bots from a single application seamlessly.
- Mail Integration: A robust email effect for composing and sending transactional emails.
- AI Provider Integration (DeepSeek): AI effect DSL tailored for interacting with LLM providers like DeepSeek. It features out-of-the-box support for strict structured responses and implements an XML-like prompt templating language which significantly improves prompt adherence.
- Production & Test Interpreters: Two distinct performers (
DefaultPerformerandTestInterpreter). The test runtime keeps the same shared-runner architecture as production, but swaps capabilities at the edges: DB can stay real, while Telegram, mail, AI, logging, and async work are captured through mocks in the capability layer. - Structured Async Logging: High-performance, asynchronous structured logging available universally across all effect types. It supports nested log contexts (e.g., automatically attaching a
user_idortrace_idto all subsequent nested calls). - Service Call Infrastructure: A transparent mechanism for running parallel typed workers with a standardized request/response interface. When the application needs concurrent background workers that process requests one at a time, register them through
LazyCircus.App.Serviceand call from scenarios withcallService— no coupling to concrete implementations. - AI Coding Agent Skill: A pre-configured custom AI skill instruction set located in
docs/skills/lazy-circus/to teach GitHub Copilot (or other AI assistants) the architectural patterns and conventions of Lazy Circus.
Lazy Circus strongly relies on domain-driven structures (Church-encoded free monads) that generic AI models might struggle to write perfectly on the first try. To solve this, we ship a custom Skill specifically designed for automated coding agents. With this skill applied, AI models at the level of GML-4.7 can successfully and accurately write business logic and tests using the framework.
When installed, the skill provides your AI assistant (e.g., GitHub Copilot) with deep context about:
- How to write and use effects via
ScriptandScenarioProgram - Appropriate usage of DB, Telegram, and Logging DSLs
- How to write tests with
TestInterpreterand assert side-effects - Haskell formatting and documentation rules used in the project
To install the Lazy Circus skill for your AI workspace:
Copy the docs/skills/lazy-circus folder into your project's AI skills directory (for example, .claude/skills/lazy-circus or a custom AI prompts folder).
Since lazy-circus is not yet available on Hackage, you need to install it directly from GitHub.
Using Cabal (cabal.project & package.yaml):
First, add the repository to your cabal.project file:
source-repository-package
type: git
location: https://github.com/BenefitWizard/lazy-circus.git
tag: <commit-hash>Then, add it to your dependencies in package.yaml (or build-depends directly in .cabal):
dependencies:
- lazy-circusUsing Stack (stack.yaml):
Add the library to your extra-deps with the repository and commit:
extra-deps:
- github: "BenefitWizard/lazy-circus"
commit: "<commit-hash>"- Overview
- 1. Effect Languages (Scene DSL)
- 2. Script — Effect Coproduct
- 3. ScenarioProgram — Orchestration Layer
- 4. Performer — Interpreters
- 5. DefaultPerformer — Production Runner
- 6. DB: Adding a New Table
- 7. Testing
- 8. Adding a New Effect
- 9. Key Patterns
- 10. Included Examples
- 11. Build and Tests
Lazy Circus is an effect framework for Haskell built on Church-encoded free monads (Control.Monad.Free.Church.F). Business logic is written using declarative Effect DSLs, completely decoupled from specific implementations (IO, mocks, etc.).
┌──────────────────────────────────────────────────────┐
│ ScenarioProgram s a │ Orchestration Layer
│ (logging, errors, time, async, context) │
├──────────────────────────────────────────────────────┤
│ Script a │ Coproduct (sum of effects)
│ TelegramScript | MailScript | AIScript | DBScript │
├──────────────────────────────────────────────────────┤
│ Scene-DSL (DBLangF, TelegramScriptF, ...) │ Effect Functors (GADT)
│ + built-in LogLangF for logging │
├──────────────────────────────────────────────────────┤
│ Performer (typeclass-interpreters) │ Execution Layer
│ runDB, runTelegram, runAI, runMail │
│ + DefaultPerformer for production │
└──────────────────────────────────────────────────────┘
Each effect is described as a GADT functor. Church-encoding (F) transforms it into a monadic program.
| Language | Program Type | Module |
|---|---|---|
| Database | DBScript db a |
LazyCircus.Scene.DB |
| Telegram | TelegramScript a |
LazyCircus.Scene.Telegram |
| AI | AIScript a |
LazyCircus.Scene.AI |
MailScript a |
LazyCircus.Scene.Mail |
|
| Logging | built into all languages | LazyCircus.Scene.Log |
import LazyCircus.Scene.DB
-- Create a record
createAct :: CircusActT Maybe -> DBScript SimpleDb (Maybe CircusAct)
createAct act = create act
-- Find by ID
findAct :: Int32 -> DBScript SimpleDb (Maybe CircusAct)
findAct actId = find (CircusActId actId)
-- Update
updateDesc :: Text -> Int32 -> DBScript SimpleDb [CircusAct]
updateDesc newDesc actId = update patch (CircusActId actId)
where patch = CircusAct Nothing Nothing Nothing (Just newDesc) Nothing
-- Delete
deleteAct :: Int32 -> DBScript SimpleDb ()
deleteAct actId = delete (CircusActId actId)
-- Transaction
transactionalAct :: DBScript SimpleDb ()
transactionalAct = withTransaction $ do
mAct <- createAct myAct
_ <- forM mAct $ \act -> updateDesc "New desc" (circusActId act)
pure ()import LazyCircus.Scene.Telegram
greetUser :: SendMessageRequest -> TelegramScript (Response Message)
greetUser request = do
response <- sendMessage request
slogInfo "Message sent"
pure responseimport LazyCircus.Scene.Mail
notifyUser :: Address -> Text -> Text -> MailScript ()
notifyUser to subject body = do
mail <- makeMail to subject body
sendMail mailimport LazyCircus.Scene.AI
getAnswer :: AIRequest MyResponse -> AIScript (Maybe MyResponse)
getAnswer request = ask requestAll Scene languages have built-in logging via HasLogLang:
slogInfo "Informational message" -- level: Info
slogWarn "Warning message" -- level: Warn
slogError "Error message" -- level: Error
slogSensitive "Debug-only message" -- level: Debug (secret)
-- Logging context (key-value, nested)
swithLogCtx [("user_id", "42")] $ do
slogInfo "This message includes user_id in context"Script is a GADT that wraps any sub-language into a single type. Using smart constructors:
import LazyCircus (tgScript, mailScript, aiScript)
-- Wrap sub-languages
tgScript "my-bot-name" myTelegramScript :: Script b
mailScript myMailScript :: Script b
aiScript myAIScript :: Script bFor DB scripts, use the DBScriptDef constructor directly:
DBScriptDef myDb ReadWrite myDbScript :: Script bScenarioProgram s a is the main type for writing business scenarios. It adds:
evalScript— execute a nestedScriptthrow/runSafely— error handlinggetDateTime— get current timelog/logInfo,logWarn,logError,logSensitive— scenario-level loggingwithLogContext,withLogEntry,with2LogEntries— logging contextgetExtraContext,readFromExtraContext,getFeatureFlag— configurationrunAsync— asynchronous executioncallService— call a registered service via the service library
import LazyCircus.Scenario
import LazyCircus (tgScript, aiScript)
myScenario :: ScenarioProgram Script ()
myScenario = do
logInfo "Starting scenario"
result <- runSafely $ do
-- Call AI
answer <- evalScript $ aiScript $ ask myRequest
-- Call Telegram
case answer of
Just response -> do
evalScript $ tgScript "bot" $ sendMessage (buildRequest response)
pure ()
Nothing ->
throw $ userError "AI returned nothing"
case result of
Left (e :: SomeException) ->
logError $ "Scenario failed: " <> tshow e
Right _ ->
logInfo "Scenario completed"Each sub-language has its own typeclass interpreter:
| Typeclass | Runner Method |
|---|---|
DBScriptPerformer m |
runDB :: PgDB db -> DbMode -> DBScript db a -> m a |
TelegramScriptPerformer m |
runTelegram :: TelegramScript a -> m a |
AILangPerformer m |
runAI :: AIScript a -> m a |
MailScriptPerformer m |
runMail :: MailScript a -> m a |
ScenarioPerformer sc m |
run :: ScenarioProgram sc a -> m a |
Interpretation occurs via iterM — folding the Church-encoded free monad into the target monadic context.
All sub-languages use a common handleLogLang handler, which:
- Extracts the current
LoggingContextfrom the reader environment - Adds a language tag (
"DB","Telegram","AI","Mail") - Extracts the call site
- Writes to a shared
TQueue
The LazyCircus.Performer.Default module provides a ready-to-use production interpreter:
import LazyCircus.Performer.Default
-- Run scenario in production environment
runDefaultScenario :: ScenarioProgram Script a -> DefaultPerformer DefaultApp aDefaultApp contains:
Connection— main PostgreSQL connectionMaybe Connection— optional read-only connectionBotEnvs—Map Text BotEnvfor Telegram botsMethods— OpenAI clientMailCreds— SMTP credentialsLogQueue/LoggingContext— loggingJWTSettings— authorizationExtraContext— arbitrary configuration (HashMap Text Text)ScheduledActions— queue of async tasks
To make a new Beam table work with DBScript, you need to implement service instances:
import LazyCircus.DB.Service
-- 1. Table must be in the DB schema
instance IsInDb MyDb MyTableT where
getTargetTable = _myTableEntity
-- 2. For INSERT
instance HasCreateService MyDb MyTableT where
generateInsert db rows = insert (_myTableEntity db) $
insertExpressions $ map (\r -> ...) rows
-- 3. For SELECT (defines lookup key type)
instance HasReadService MyDb MyTableT where
type LId MyTableT = MyTableId -- injective type family
generateSelect db lid = lookup_ (_myTableEntity db) lid
generateFiltration _db lid t = myTableId t ==. val_ (unMyTableId lid)
-- 4. For UPDATE
instance HasUpdateService MyDb MyTableT where
generateAssigment _db partial t = ...
-- 5. For DELETE (often default implementation is enough)
instance HasDeleteService MyDb MyTableTReadWrite— all operations allowedReadOnly— read-only; write operations throwDbReadOnlyViolation
import LazyCircus.Scene.DB
-- Transaction with RLS context
withTransactionRLS (rlsCircusId 42) $ do
findAll someLookup -- only sees rows where circus_id = 42The LazyCircus.Testing.Performer module provides a mock interpreter for tests:
Tests reuse the same high-level runner structure as production:
ScenarioProgramis interpreted through the normal scenario machineryScriptdispatch still routes into DB / Telegram / Mail / AI branches- environment projection still happens through wrapper envs such as
AppWithConnectionandAppWithBotEnv
What changes is the capability layer underneath that runner:
- DB typically uses a real PostgreSQL connection
- Telegram uses capture mocks for immediate and scheduled sends
- Mail uses real mail building but mocked send capture
- AI returns mock answers (
Nothingby default) runAsyncrecords deferred scenarios instead of executing them- logging is captured as structured messages with context and call-site metadata
This shared-runner design lets tests validate orchestration behavior very close to production while still giving precise observability over side effects.
import LazyCircus.Testing.Performer
myTest :: DefaultApp -> IO ()
myTest app = do
(mocks, result) <- runWithDefaultMocks app $ do
runScenarioProgram myScenario
-- Check captured Telegram requests
tgReqs <- readTgRequests mocks
tgReqs `shouldSatisfy` (not . null)
-- Check logs
logs <- readLog mocks
logs `shouldSatisfy` elem (AppLogMsg "Scenario completed")
-- Check sent emails
mails <- readSentMails mocks
mails `shouldSatisfy` (not . null)
-- Check async tasks
asyncs <- readScheduledScenarios mocks
length asyncs `shouldBe` 1-- Simple mock (default response for all sendMessage)
mocks <- makeMocks
-- Custom mock with response queue
tgMock <- createTgMock defaultResponse $ Just [myCustomResponse]| Effect | Behavior in Tests |
|---|---|
Telegram sendMessage |
Capture requests, returns canned responses |
Telegram scheduleMessages |
Captured in a separate list |
| Missing Telegram bot | Throws NoBotConfigured during script dispatch |
| Telegram others | no-op / default |
Mail sendMail |
Capture Mail values |
Mail makeMail |
Actual creation via SMTP credentials from env |
AI ask |
Always returns Nothing |
| DB | Real execution against DB (requires test database) |
| Logging | Capture in ref-lists (no production queue writes) |
runAsync |
Capture ScenarioProgram without execution |
- use
readTgRequeststo inspect immediate Telegram sends - use
readScheduledTgRequeststo inspect deferred Telegram sends - use
readScheduledScenariosto inspectrunAsynccapture - use
readLogWithContextwhen you need to assertwithLogContext,lang, or call-site metadata - if needed, rerun a captured async scenario in the same test runtime to verify its downstream effects
To add a new sub-language:
-- src/LazyCircus/Scene/MyEffect/Lang.hs
module LazyCircus.Scene.MyEffect.Lang where
import Control.Monad.Free.Church (F)
import Control.Monad.Free.Church qualified as CF
import LazyCircus.Scene.Log (HasLogLang (..), LogLangF)
data MyEffectF a where
DoSomething :: Text -> (Result -> a) -> MyEffectF a
MyEffectLog :: LogLangF MyEffect b -> (b -> a) -> MyEffectF a
instance Functor MyEffectF where
fmap f (DoSomething txt next) = DoSomething txt (f . next)
fmap f (MyEffectLog logOp next) = MyEffectLog logOp (f . next)
instance HasLogLang MyEffectF MyEffect where
embedLog logOp = MyEffectLog logOp id
doSomething :: Text -> MyEffect Result
doSomething txt = CF.liftF $ DoSomething txt id
type MyEffect = F MyEffectF-- src/LazyCircus/Scene/MyEffect/Class.hs
module LazyCircus.Scene.MyEffect.Class where
import Control.Monad.Free.Church (iterM)
import LazyCircus.Scene.MyEffect.Lang
import LazyCircus.Scene.Log (handleLogLang)
class (Monad m) => MyEffectPerformer m where
doSomething' :: Text -> m Result
runMyEffect :: (MyEffectPerformer m, HasLogQueue env, HasLoggingContext env,
MonadReader env m, MonadIO m) => MyEffect a -> m a
runMyEffect = iterM go
where
go (DoSomething txt next) = do
result <- doSomething' txt
next result
go (MyEffectLog logOp next) = handleLogLang "MyEffect" runMyEffect (fmap next logOp)-- In Script.hs add constructor:
data Script b where
...
MyEffectDef :: MyEffect b -> Script b
-- In Performer.hs update onEvalScript:
onEvalScript (MyEffectDef scr) = runMyEffect scrAll resources are accessible via lens-based typeclasses:
class HasDbConnection env where
dbConnectionL :: Lens' env Connection
class HasLogQueue env where
logQueueL :: Lens' env (TQueue AppLogMsgWithContext)
class HasLoggingContext env where
logContextL :: Lens' env LoggingContextThis allows composing environments via wrapper types (AppWithConnection, AppWithBotEnv).
type Runner r m = forall a. r a -> m aA rank-2 alias for natural transformations from an effect language to an interpreting monad.
changeEnv :: (outer -> inner) -> DefaultPerformer inner a -> DefaultPerformer outer aProjects a performer onto an outer environment via a projection function.
The repository ships with two ready-to-run executables and a shared internal library that demonstrates real-world usage of every Lazy Circus sub-language.
Entry point: app/example/Example.hs
Run: ./run-example.sh or stack run example
The demo connects to PostgreSQL, runs eight scenarios sequentially, and prints the result of each:
| # | Scenario | What it demonstrates |
|---|---|---|
| 1 | dbCrudScenario |
Basic CRUD: create → find → update → findAll → delete |
| 2 | dbAdvancedScenario |
createMany, withTransaction, rawQuery, withTransactionRLS |
| 3 | telegramScenario |
getBotName, sendMessage, sendImportantMessage, scheduleMessage, setMessageReaction, editMessageText (skipped if TG_CHAT_ID not set) |
| 4 | mailScenario |
makeMail + sendMail |
| 5 | aiScenario |
Typed AIRequest with structured JSON decoding (skipped if AI_API_KEY not set) |
| 6 | loggingScenario |
All four log levels, withLogContext, withLogEntry, sub-language logging via swithLogCtx |
| 7 | orchestrationScenario |
getDateTime, getExtraContext, readFromExtraContext, getFeatureFlag, throw/runSafely, runAsync |
| 8 | fullCircusLifecycleScenario |
End-to-end: DB create in transaction → extra context → logging context → mail → async cleanup → runSafely |
Entry point: app/bot/BotMain.hs
Run: ./run-bot.sh or stack run bot
Requires: TG_TOKEN environment variable
A conversational Telegram bot that manages circus acts. Commands:
| Command | Behaviour |
|---|---|
/start |
Shows help message |
/newact |
Two-step dialog: name → description → creates act + AI reaction + notification email |
/list |
Lists all acts from DB |
/act <id> |
Shows act details |
/react <id> |
Regenerates AI audience reaction for an act |
/delete <id> |
Deletes an act |
The bot uses BotScenarios for all business logic, keeping the Telegram layer thin.
Both examples need PostgreSQL running at 127.0.0.1:5432 (user postgres, password my_password). The demo automatically creates/drops the lazy_circus_test database. A docker-compose.yml is provided for convenience.
docker compose up -d # start PostgreSQL
cp .env.example .env # configure API keys and tokens# Update cabal file (required before building)
hpack
# Build project
stack build
# Run tests
stack testDB tests require a running PostgreSQL with user postgres (password my_password) on 127.0.0.1:5432. Tests create and drop the lazy_circus_test database automatically.