Skip to main content

Services & Selectors

Maybern separates business logic into services and data access into selectors. This creates clear boundaries and makes code easier to test and maintain.

Architecture Overview

Public Services

Public services are the external interface for your app. Other apps should only import from the public layer.
# public/services.py
from typing import List, Optional

from server.apps.core.dataclasses.request_ctx import RequestCtx
from server.apps.core.types.muuid import MUUID
from server.apps.my_app.private.selectors.entity import get_entity, list_entities
from server.apps.my_app.private.services.entity import create_entity as _create_entity
from server.apps.my_app.public.dataclasses import EntityCreateData, EntityResponse


class MyAppService:
    """
    Public service interface for my app.
    This is the primary interface other apps should use.
    """
    
    @staticmethod
    def get_entity(
        *, 
        ctx: RequestCtx, 
        entity_id: MUUID,
        include_details: bool = False,
    ) -> EntityResponse:
        """
        Gets an entity by ID.
        
        Args:
            ctx: Request context
            entity_id: Entity ID
            include_details: Whether to include additional details
            
        Returns:
            EntityResponse with entity data
        """
        entity = get_entity(ctx=ctx, entity_id=entity_id)
        return EntityResponse(
            id=entity.id,
            name=entity.name,
            description=entity.description,
            # ...other fields
        )
    
    @staticmethod
    def create_entity(
        *,
        ctx: RequestCtx,
        entity_data: EntityCreateData,
    ) -> EntityResponse:
        """Creates a new entity."""
        entity = _create_entity(
            ctx=ctx,
            name=entity_data.name,
            description=entity_data.description,
            parent_id=entity_data.parent_id,
        )
        return EntityResponse(id=entity.id, name=entity.name, ...)
Public services should have clear interfaces with typed parameters and return values. Use dataclasses for complex inputs/outputs.

Private Services

Private services contain internal business logic that shouldn’t be accessed directly by other apps.
# private/services/entity.py
from datetime import date
from django.db import transaction

from server.apps.core.dataclasses.request_ctx import RequestCtx
from server.apps.core.types.muuid import MUUID
from server.apps.my_app.models.entity import MyEntity
from server.apps.my_app.public.constants import EntityStatus


@transaction.atomic
def create_entity(
    *,
    ctx: RequestCtx,
    parent_id: Optional[MUUID],
    name: str,
    description: str,
    status: EntityStatus = EntityStatus.DRAFT,
    effective_date: date = None,
) -> MyEntity:
    """
    Internal service to create an entity.
    
    Args:
        ctx: Request context
        parent_id: Optional parent entity ID
        name: Entity name
        description: Entity description
        status: Entity status
        effective_date: Effective date
        
    Returns:
        Created MyEntity instance
    """
    effective_date = effective_date or date.today()
    
    entity = MyEntity(
        customer_id=ctx.customer_id,
        parent_id=parent_id,
        name=name,
        description=description,
        status=status,
        effective_date=effective_date,
    )
    entity.save()
    
    # Additional business logic...
    _trigger_post_create_hooks(ctx=ctx, entity=entity)
    
    return entity

When to Use Private Services

Use private services when:
  • Logic is specific to this app only
  • Implementation details that shouldn’t be exposed
  • Complex multi-step operations
  • Operations that need to be composed

Selectors

Selectors provide consistent data access patterns. Use selectors instead of querying models directly.
# private/selectors/entity.py
from django.db.models import Q
from typing import List, Optional

from server.apps.core.dataclasses.request_ctx import RequestCtx
from server.apps.core.exceptions import MaybernError
from server.apps.core.types.muuid import MUUID
from server.apps.my_app.models.entity import MyEntity


def get_entity(*, ctx: RequestCtx, entity_id: MUUID) -> MyEntity:
    """
    Gets an entity by ID.
    
    Args:
        ctx: Request context
        entity_id: Entity ID
        
    Returns:
        MyEntity instance
        
    Raises:
        MaybernError: If entity not found
    """
    try:
        return MyEntity.objects.get(
            customer_id=ctx.customer_id,
            id=entity_id,
        )
    except MyEntity.DoesNotExist:
        raise MaybernError(f"Entity {entity_id} not found")


def list_entities(
    *,
    ctx: RequestCtx,
    status: Optional[EntityStatus] = None,
    parent_id: Optional[MUUID] = None,
) -> List[MyEntity]:
    """
    Lists entities with optional filters.
    
    Args:
        ctx: Request context
        status: Optional status filter
        parent_id: Optional parent filter
        
    Returns:
        List of MyEntity instances
    """
    queryset = MyEntity.objects.filter(customer_id=ctx.customer_id)
    
    if status:
        queryset = queryset.filter(status=status)
    if parent_id:
        queryset = queryset.filter(parent_id=parent_id)
    
    return list(queryset)


def get_entity_with_children(*, ctx: RequestCtx, entity_id: MUUID) -> MyEntity:
    """Gets an entity with related children prefetched."""
    return MyEntity.objects.prefetch_related("children").get(
        customer_id=ctx.customer_id,
        id=entity_id,
    )

Selector Benefits

Consistency

Same query logic used everywhere, reducing bugs from inconsistent filters.

Testability

Easy to mock selectors in unit tests without touching the database.

Optimization

Centralized place to add prefetching, caching, or query optimization.

Multi-tenancy

Customer filtering is always applied, preventing data leaks.

Public Dataclasses

Define typed data structures in the public layer:
# public/dataclasses.py
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from typing import List, Optional

from server.apps.core.types.muuid import MUUID
from server.apps.my_app.public.constants import EntityStatus


@dataclass
class EntityBaseData:
    """Base data shared across entity operations."""
    name: str
    description: str = ""
    status: EntityStatus = EntityStatus.DRAFT
    

@dataclass
class EntityCreateData(EntityBaseData):
    """Data required to create an entity."""
    parent_id: Optional[MUUID] = None
    effective_date: date = field(default_factory=date.today)
    

@dataclass
class EntityResponse(EntityBaseData):
    """Data returned in entity responses."""
    id: MUUID
    created_at: datetime
    updated_at: datetime
    parent_id: Optional[MUUID] = None

Using dataclasses.replace()

When creating variations of dataclasses:
from dataclasses import replace

# Create a new dataclass based on an existing one
original_data = EntityCreateData(
    name="Original Name",
    description="Original Description",
)

# Replace only specific fields
updated_data = replace(original_data, name="Updated Name")

Public Constants

Define enums and constants in the public layer:
# public/constants.py
from enum import Enum, auto
from server.apps.core.constants import StrEnum


class EntityStatus(StrEnum):
    """Represents the status of an entity."""
    DRAFT = "draft"
    ACTIVE = "active"
    ARCHIVED = "archived"


class EntityType(Enum):
    """Represents the type of an entity."""
    PRIMARY = auto()
    SECONDARY = auto()

Best Practices

Public Layer should contain:
  • Service interfaces for other apps
  • Dataclasses for request/response
  • Constants and enums
Private Layer should contain:
  • Implementation details
  • Internal business logic
  • Data access selectors
  • Use @staticmethod for service methods
  • Always require ctx: RequestCtx as first parameter
  • Use keyword-only arguments (*)
  • Return typed dataclasses, not models
  • Add comprehensive docstrings
  • Name after what they return: get_entity, list_entities
  • Always filter by customer_id from context
  • Raise MaybernError for not found cases
  • Return model instances (services convert to dataclasses)
  • Add prefetching when needed
Do:
  • Import from other_app.public.services
  • Import from other_app.public.dataclasses
  • Import from other_app.public.constants
Don’t:
  • Import from other_app.private.*
  • Import models from other apps directly
  • Access other app’s database tables directly

Next Steps