Skip to content

Integration Patterns

How different parts of Aegis Stack connect and communicate.

About This Guide

This page explains the architectural patterns used throughout Aegis Stack. The business logic layer pattern described here applies to all services—whether built-in Aegis Services (auth, ai) that you add via --services or custom business services you write yourself.

For information about which Aegis Services are available, see Services Overview.

Business Logic Layer

Aegis Stack uses a service layer to keep business logic separate from infrastructure components. Services are Python classes that orchestrate workflows and can be called from anywhere.

What Goes Where

Services (app/services/):

  • Database interactions, external API calls, file processing
  • Complex business logic and data transformations
  • Pure functions that can be unit tested
  • Single files (report_service.py) or folders (system/health.py) for complex domains

Components (app/components/):

  • API endpoints, scheduled jobs, background tasks, UI handlers
  • Import services explicitly - no magic auto-discovery
  • Keep thin - handle requests, call services, return responses

Why explicit imports? Makes dependencies clear, prevents surprises, easier testing.

Service Structure Example

Services contain your business logic and can call other services:

# app/services/user_service.py
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.user import User, UserCreate
from app.services.email_service import EmailService

class UserService:
    """Service for managing users."""

    def __init__(self, db: AsyncSession):
        self.db = db
        self.email_service = EmailService()

    async def create_user(self, user_data: UserCreate) -> User:
        """Create user and send welcome email."""
        # Step 1: Create user in database
        user = User(
            email=user_data.email,
            full_name=user_data.full_name,
            hashed_password=hash_password(user_data.password)
        )
        self.db.add(user)
        await self.db.commit()
        await self.db.refresh(user)

        # Step 2: Send welcome email
        await self.email_service.send_welcome_email(user.id)

        return user

This service can be used from any component - API, CLI, Worker, or Scheduler.

Component → Service Patterns

All components follow the same pattern: import the service, call the method. This keeps your business logic consistent whether triggered by HTTP request, CLI command, scheduled job, or background task.

API → Service

Routes call service methods:

# app/components/backend/api/users.py
from app.services.user_service import UserService
from app.core.db import db_session

@router.post("/users")
async def create_user_endpoint(data: UserCreate):
    with db_session() as session:
        user_service = UserService(session)
        user = await user_service.create_user(data)
    return user

CLI → Service

Commands call the same service methods:

# app/cli/users.py
from app.services.user_service import UserService
from app.core.db import db_session

@app.command()
async def create_user(email: str, name: str):
    """Create a new user."""
    with db_session() as session:
        user_service = UserService(session)
        data = UserCreate(email=email, full_name=name)
        user = await user_service.create_user(data)
    print(f"Created user: {user.email}")

Worker → Service

Background tasks call service methods through thin wrappers:

# app/components/worker/tasks/user_tasks.py
from typing import Any
from app.services.user_service import UserService
from app.core.db import db_session

async def create_user_task(ctx: dict[str, Any], user_data: dict) -> dict:
    """Create user as background job."""
    with db_session() as session:
        user_service = UserService(session)
        data = UserCreate(**user_data)
        user = await user_service.create_user(data)
    return {"user_id": user.id, "email": user.email}

# Register in WorkerSettings
class WorkerSettings:
    functions = [create_user_task]

Scheduler → Service

Scheduled jobs call service methods directly:

# app/components/scheduler.py
from app.services.cleanup_service import CleanupService
from app.core.db import db_session

async def cleanup_inactive_users():
    """Scheduled task to cleanup inactive users."""
    with db_session() as session:
        cleanup_service = CleanupService(session)
        await cleanup_service.deactivate_inactive_users(days=90)

scheduler.add_job(cleanup_inactive_users, 'cron', hour=2)

Consistent Behavior

The workflow stays the same whether called from API, CLI, worker, or scheduler:

# One service class
class AnalyticsService:
    async def track_event(self, event: str, **data) -> None:
        """Track analytics event."""
        ...

# Multiple entry points
 API: Track endpoint calls
 CLI: Track manual operations
 Worker: Track background job completion
 Scheduler: Track scheduled task execution

One service class. Multiple entry points. Consistent behavior.

Cross-Component Patterns

Sometimes components need to communicate with each other, not just call services.

Scheduler → Worker

Scheduler can trigger worker tasks for heavy operations:

# app/components/scheduler.py
from app.components.worker.pools import get_queue_pool

async def schedule_daily_reports():
    """Scheduler triggers worker to generate reports."""
    pool, _ = await get_queue_pool("system")
    await pool.enqueue_job("generate_daily_report")
    await pool.aclose()

# Schedule daily at 2 AM
scheduler.add_job(schedule_daily_reports, 'cron', hour=2)

Or execute lightweight tasks directly:

# app/components/scheduler.py
from app.services.cleanup_service import CleanupService
from app.core.db import db_session

async def cleanup_temp_files():
    """Fast cleanup - run directly in scheduler."""
    with db_session() as session:
        cleanup = CleanupService(session)
        await cleanup.remove_temp_files()

scheduler.add_job(cleanup_temp_files, 'interval', hours=1)

When to use Worker vs Direct: - Direct: Lightweight tasks (< 1 second), no retry needed - Worker: Heavy tasks, need retry logic, want queue management

API → Worker

Queue background tasks from API endpoints:

# app/components/backend/api/reports.py
from app.components.worker.pools import get_queue_pool

@router.post("/reports/generate")
async def queue_report(user_id: int):
    """Queue report generation as background job."""
    pool, _ = await get_queue_pool("system")
    job = await pool.enqueue_job("generate_report", user_id)
    await pool.aclose()
    return {"job_id": job.job_id, "status": "queued"}

Frontend ↔ Backend

Flet frontend runs in the same process as FastAPI backend, so it can call services directly:

# app/components/frontend/pages/dashboard.py
from app.services.system import get_system_status

async def update_dashboard():
    """Update dashboard with system status."""
    status = await get_system_status()
    # Update Flet UI with status data

For generated projects, Frontend and Backend share the same container, making this direct access fast and simple.

Container Boundaries

Each component runs in its own Docker container for independent scaling:

  • Backend Container: Runs FastAPI + Flet (Frontend)
  • Scheduler Container: Runs APScheduler for scheduled jobs
  • Worker Container: Runs arq for background task processing

What's Shared:

  • Services (app/services/)
  • Core utilities (app/core/)
  • Configuration (app/core/config)
  • Database sessions

What's Isolated:

  • Component-specific hooks and startup logic
  • Resource allocation (CPU, memory)
  • Scaling decisions (can scale workers independently)

Important: Backend startup hooks don't affect scheduler, and vice versa. Each container manages its own lifecycle.

Data Validation Boundaries

Trust Zones: Validate at entry points, trust internally.

Validate Hard at Entry Points

API endpoints, CLI commands, and worker tasks validate incoming data:

# Entry point - validate with Pydantic
@router.get("/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
    # Internal code - trust the data
    status = await get_system_status()
    return HealthResponse(healthy=status.overall_healthy, ...)

Trust Internally

Once validated, internal code trusts the data:

# CLI - validate API response
health_data = HealthResponse.model_validate(response.json())

# Then trust: direct attribute access
print(f"System healthy: {health_data.healthy}")  # Not .get("healthy")

Validation Layers: 1. API Endpoints: Pydantic response_model validates outgoing data 2. CLI Commands: Pydantic models validate API responses 3. Internal Code: Direct model attribute access (no .get() patterns)

Testing Services

Services can be tested directly without running components:

# tests/services/test_user_service.py
async def test_create_user(db_session):
    """Test user creation workflow."""
    user_service = UserService(db_session)

    data = UserCreate(email="test@example.com", full_name="Test User")
    user = await user_service.create_user(data)

    assert user.email == "test@example.com"
    assert user.id is not None

No API server needed. No worker process needed. Just call the service.

This is why the service layer is powerful - it's testable in isolation.

Configuration Access

Global Settings: Available everywhere via explicit import.

from app.core.config import settings

# Use throughout application
database_url = settings.DATABASE_URL
api_timeout = settings.API_TIMEOUT

Constants vs Config:

  • Constants (app.core.constants): Immutable values (API paths, default timeouts)
  • Config (app.core.config): Environment-dependent values (URLs, secrets, feature flags)

Next Steps