This content originally appeared on DEV Community and was authored by Martin Persson
Building a Robust Backend with Effect, GraphQL, and Drizzle
Link to Github repo
Table of Contents
- Introduction
- The Problem
-
The Solution
- Effect: Functional Effects for Robust Error Handling
- Gradual Migration: Mix Effect and Promises
- Branded Types: Type Safety That Actually Works
-
Architecture Overview
- Domain Layer
- Repository Layer
-
Key Features
- Robust Error Handling
- Performance Optimizations
- Circular Relationship Prevention
- Tree Operations
- Important Disclaimer: This is a Demo
-
Benefits
- Compile-Time Safety
- Debugging-Friendly
- Testable Code
- Maintainable
-
Getting Started
- Available Scripts
- GraphQL API
- Key Takeaways
- Conclusion
Introduction
This post demonstrates how to build a type-safe, robust backend using Effect, GraphQL, and Drizzle ORM. I built a hierarchical ticket management system to showcase functional programming principles in a real-world application.
The project shows how to handle complex business logic with proper error handling, type safety, and gradual migration from existing codebases. It's designed to be educational rather than production-optimized.
The Problem
Most backend applications suffer from:
- Silent failures: Errors get swallowed by generic catch blocks
- Type safety theater: TypeScript types that don't actually prevent runtime errors
- Tight coupling: Business logic mixed with infrastructure concerns
- Poor error handling: Generic error messages that don't help debugging
- Difficult testing: Business logic entangled with side effects
- Hard to reason about: Complex async flows that are hard to follow
The Solution
A ticket management system that handles hierarchical data with proper error handling and type safety. The system supports:
- Hierarchical ticket structures (parent-child relationships)
- Cascade operations (toggle, delete entire subtrees)
- Circular relationship prevention
- Parallel processing for performance
- Comprehensive error handling with specific error types
1. Effect: Functional Effects for Robust Error Handling
Effect is a TypeScript library that brings functional programming principles to the real world. Instead of throwing errors or returning null, Effect forces you to handle all possible outcomes explicitly.
// Traditional approach - fragile
const findTicket = (id: number) => {
const ticket = database.find(id);
if (!ticket) throw new Error("Not found");
return ticket;
}
// Effect approach - robust
const findTicket = (id: TicketId) =>
Effect.gen(function* () {
const ticket = yield* repository.findById(id);
return yield* Effect.flatMap(
ticket,
Option.match({
onNone: () => Effect.fail(new TicketNotFoundError(`Failed to find ticket with ID ${id}`)),
onSome: Effect.succeed,
})
);
});
Effect provides several benefits:
- Explicit error handling: All possible failure cases must be handled
- Composable operations: Chain operations together safely
- Dependency injection: Services are injected through layers
- Type safety: Compile-time guarantees about error handling
2. Gradual Migration: Mix Effect and Promises
You don't need to rewrite everything at once. The adapter allows mixing Effect-based and Promise-based resolvers:
export const enableEffectResolvers = (schema: GraphQLSchema) => {
return mapSchema(schema, {
[MapperKind.ROOT_FIELD]: (fieldConfig) => {
const originalResolver = fieldConfig.resolve
if (!originalResolver) {
return fieldConfig
}
return {
...fieldConfig,
async resolve(parent, args, context, info) {
const result = originalResolver(parent, args, context, info)
if (Effect.isEffect(result)) {
return await Effect.runPromise(result.pipe(Effect.provide(AppLayer)))
}
// This adapter allows mixing Effect and Promise-based resolvers in the same codebase.
// If the resolver returns a Promise instead of an Effect, it will be executed as-is.
// This enables incremental migration from Promise-based to Effect-based code without
// requiring a complete rewrite of all resolvers at once.
},
}
},
})
}
This approach enables:
- Incremental adoption: Start with new features using Effect
- Risk mitigation: Keep existing code working while migrating
- Team learning: Gradually introduce functional concepts
- Proof of concept: Demonstrate benefits before full migration
3. Branded Types: Type Safety That Actually Works
Instead of using plain numbers for IDs, branded types prevent mixing up different kinds of IDs:
export type TicketId = number & Brand.Brand<"TicketId">
export const TicketIdSchema = Schema.NumberFromString.pipe(
Schema.int(),
Schema.nonNegative(),
Schema.brand("TicketId")
)
This means you can't accidentally pass a UserId
where a TicketId
is expected - TypeScript will catch it at compile time.
Benefits of branded types:
- Prevent bugs: TypeScript catches ID mixing at compile time
- Self-documenting: Code clearly shows what type of ID is expected
- Refactoring safety: Changes to ID types are caught immediately
- Better IDE support: Autocomplete and error detection work correctly
Architecture Overview
The project follows a clean architecture pattern with clear separation of concerns:
domain/ # Business logic, models, schemas
├── ticket/
│ ├── model.ts # Drizzle table definitions
│ ├── schema.ts # Input validation & branded types
│ ├── service.ts # Business logic
│ ├── repository.ts # Database access
│ └── helpers.ts # Tree manipulation utilities
graphql/ # API layer
├── resolvers/ # GraphQL resolvers with error handling
└── schema/ # GraphQL type definitions
lib/ # Infrastructure
├── db.ts # Database setup
└── utils.ts # Response helpers
adapter/ # Integration layer
└── effectAdapter.ts # Effect integration with GraphQL
Domain Layer
The domain layer contains all business logic and is completely independent of infrastructure concerns:
export class TicketService extends Effect.Service<TicketService>()("Ticket/Service", {
effect: Effect.gen(function* () {
const repository = yield* TicketRepository
const helper = yield* HelperService
const toggleTicket = (input: typeof ToggleTicketInput.Type) =>
Effect.gen(function* () {
// Business logic here
})
return {
findById,
createTicket,
findAll,
deleteTicket,
toggleTicket,
removeParentFromTicket,
setParentOfTicket,
addChildrenToTicket,
} as const
}),
dependencies: [TicketRepository.Default, HelperService.Default],
})
Repository Layer
The repository layer handles database operations with proper error handling:
export class TicketRepository extends Effect.Service<TicketRepository>()("Ticket/Repo", {
effect: Effect.gen(function* () {
const db = yield* SqliteDrizzle.SqliteDrizzle
const findById = (id: TicketId) => {
return Effect.gen(function* () {
const rowsResult = yield* db.select().from(tickets).where(eq(tickets.id, id))
return Option.fromNullable(rowsResult[0])
})
}
return {
findById,
createTicket,
deleteTicket,
findAll,
toggleTicket,
findChildren,
removeParent,
setParent,
} as const
}),
dependencies: [DatabaseLive],
})
Key Features
Robust Error Handling
Every operation has comprehensive error handling with specific error types:
Effect.catchTags({
ParseError: (error) => Effect.gen(function* () {
yield* Effect.logError(`❌ Invalid input format: ${error.message}`);
return yield* errorResponse(`Invalid parameters. Please check your input.`);
}),
TicketNotFoundError: (error) => Effect.gen(function* () {
yield* Effect.logError(`🔍 Ticket not found: ${error.message}`);
return yield* errorResponse(`Ticket not found. Cannot perform operation on non-existent ticket.`);
}),
CircularRelationshipError: (error) => Effect.gen(function* () {
yield* Effect.logError(`🔄 Circular relationship detected: ${error.message}`);
return yield* errorResponse(`Cannot create circular parent-child relationship.`);
}),
TimeoutException: (error) => Effect.gen(function* () {
yield* Effect.logError(`⏰ Operation timeout: ${error.message}`);
return yield* errorResponse(`Operation took too long. Please try again.`);
}),
SqlError: (error) => Effect.gen(function* () {
yield* Effect.logError(`�� Database error: ${error.message}`);
return yield* errorResponse(`Database connection issue. Please try again later.`);
}),
});
Performance Optimizations
Parallel processing for operations that affect multiple tickets:
yield* Effect.forEach(
allTickets,
(ticket) => repository.toggleTicket({ id: ticket.id as TicketId, isCompleted: input.isCompleted }),
{ concurrency: 5 }
);
Circular Relationship Prevention
The system prevents circular relationships in the ticket hierarchy:
const checkCircularRelationships = (childId: TicketId, parentId: TicketId) =>
Effect.gen(function* () {
const visited = new Set<TicketId>([childId])
let currentId = parentId
while (true) {
if (visited.has(currentId)) {
return yield* Effect.fail(
new CircularRelationshipError(
`Circular relationship detected: setting ticket ${childId} as parent of ${parentId} would create a cycle`
)
)
}
visited.add(currentId)
const current = yield* repository.findById(currentId)
const result = Option.match(current, {
onNone: () => false,
onSome: (ticket) => {
if (ticket.parentId === null) {
return false
}
currentId = ticket.parentId as TicketId
return true
},
})
if (!result) {
return yield* Effect.void
}
}
})
Tree Operations
The system supports complex tree operations like cascade toggle and delete:
const enrichTicketTree = (rootTicket: Ticket, maxDepth = 10) =>
Effect.gen(function* () {
const queue = yield* Queue.unbounded<TicketWithChildren>()
const withChildren = yield* getMaybeChildren(rootTicket)
yield* Queue.offer(queue, withChildren)
let depth = 0
while (depth++ < maxDepth) {
const currentLevel = yield* Queue.takeUpTo(queue, 100)
for (const ticket of currentLevel) {
if (ticket.children.length > 0) {
for (const child of ticket.children) {
const enriched = yield* getMaybeChildren(child)
yield* Queue.offer(queue, enriched)
ticket.children = ticket.children.map((child) => (child.id === enriched.id ? enriched : child))
}
}
}
if (currentLevel.length === 0) {
break
}
}
return withChildren
})
Important Disclaimer: This is a Demo
This ticket system is designed to showcase Effect and functional programming principles, not to be the most efficient ticket management system.
For example, I'm doing recursive tree traversal and multiple database calls where a few well-crafted SQL queries could do the same job more efficiently. But that would defeat the purpose of demonstrating Effect's capabilities.
The goal is to show how Effect can handle complex business logic with proper error handling, not to optimize for database performance.
Benefits
1. Compile-Time Safety
TypeScript catches most errors before runtime, and Effect ensures all error cases are handled:
- Type safety: Branded types prevent ID mixing
- Error handling: All failure cases must be explicitly handled
- Dependency injection: Services are properly injected and typed
- Composition: Operations can be safely composed
2. Debugging-Friendly
Specific error types and comprehensive logging:
🔄 Starting cascade toggle for ticket ID: 1 to true
Toggling 6 tickets to true
✅ Cascade toggle completed: 6 tickets updated
The logging system provides:
- Structured logs: Each operation is clearly logged
- Error context: Specific error types with meaningful messages
- Operation tracking: Easy to follow the flow of operations
- Performance insights: Timing and concurrency information
3. Testable Code
The functional approach makes testing straightforward:
describe("TicketService", () => {
describe("findById", () => {
it("should find a ticket by id", async () => {
const TicketServiceMock = new TicketService({
...DefaultServiceMockImplementation,
findById: vi.fn().mockReturnValue(
Effect.succeed({
id: 1 as TicketId,
title: "Test Ticket",
description: "Test Description",
parentId: null,
completed: false,
createdAt: new Date(),
updatedAt: new Date(),
})
),
})
const result = await Effect.runPromise(
Effect.gen(function* () {
const ticketService = yield* TicketService
const ticket = yield* ticketService.findById(1 as TicketId)
return ticket
}).pipe(Effect.provide(Layer.succeed(TicketService, TicketServiceMock)))
)
expect(result.title).toBe("Test Ticket")
expect(TicketServiceMock.findById).toHaveBeenCalledWith(1)
})
})
})
Benefits of this testing approach:
- Pure functions: Easy to test business logic in isolation
- Mockable dependencies: Services can be easily mocked
- Type safety: Tests benefit from the same type safety as production code
- Comprehensive coverage: All error cases can be tested
4. Maintainable
Clear separation of concerns and explicit error handling make the codebase easy to understand and modify:
- Separation of concerns: Business logic, data access, and API layers are separate
- Explicit dependencies: All dependencies are clearly declared
- Consistent patterns: Effect patterns are used consistently throughout
- Documentation: Code is self-documenting through types and structure
Getting Started
git clone <repository>
npm install
npm run db:seed
npm run dev
The system comes with a comprehensive GraphQL API at http://localhost:4000
.
Available Scripts
-
npm start
: Start the production server -
npm run dev
: Start the development server with hot reload -
npm run db:seed
: Seed the database with sample data -
npm run db:drop
: Delete the database file -
npm run db:reset
: Reset the database (drop + seed) -
npm test
: Run tests -
npm run test:watch
: Run tests in watch mode -
npm run test:coverage
: Run tests with coverage
GraphQL API
The API provides the following operations:
Queries:
-
findAll
: Get root tickets with pagination -
findById
: Get a specific ticket with its complete tree
Mutations:
-
createTicket
: Create a new root ticket -
toggleTicket
: Toggle completion status (cascade) -
deleteTicket
: Delete a ticket and all descendants -
removeParentFromTicket
: Convert child to root ticket -
setParentOfTicket
: Move ticket to new parent -
addChildrenToTicket
: Add multiple children to a parent
Key Takeaways
- Functional programming solves real problems in production code
- Gradual migration is possible - you don't need to rewrite everything at once
- Type safety matters - branded types prevent entire classes of bugs
- Error handling should be explicit - don't let errors slip through the cracks
- Effect works great with existing tools - Apollo GraphQL Server, Drizzle ORM, etc.
- Testing becomes easier with pure functions and explicit dependencies
- Code is more maintainable with clear separation of concerns
- Debugging is improved with structured logging and specific error types
Conclusion
Effect, combined with GraphQL and Drizzle, creates a development experience that's both productive and reliable. The functional approach forces you to handle errors explicitly while maintaining type safety throughout the application.
The gradual migration approach makes it practical to adopt these patterns in existing codebases, while the comprehensive error handling and testing capabilities make the resulting code more robust and maintainable.
This approach isn't just about using trendy functional programming concepts - it's about building software that's easier to reason about, test, and maintain in the long run.
This content originally appeared on DEV Community and was authored by Martin Persson

Martin Persson | Sciencx (2025-08-19T09:46:57+00:00) Building a Robust Backend with Effect, GraphQL, and Drizzle. Retrieved from https://www.scien.cx/2025/08/19/building-a-robust-backend-with-effect-graphql-and-drizzle/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.