--- title: 'Composition Over Copy-Paste: Building Reusable Behaviors with Type Discriminators in SQLAlchemy' description: 'You know that feeling when you need to add the same feature to multiple models in your app? Like, you want several different entities to have comments, or alerts, or notifications...' pubDate: 'December 22, 2025' tags: ['Python', 'SQLAlchemy', 'Patterns', 'Architecture', 'HowTo'] --- Working on a booking platform, we needed alerts and notes that could attach to any entity – bookings, users, profiles. The obvious approach was copy-pasting relationships everywhere. Instead, we built reusable behaviors using composition: write it once, add it to any model. This isn't a SQLAlchemy trick – it's an architectural pattern that works anywhere. Here's how we built composable behaviors that work across the entire system. ## The Problem: Cross-Cutting Behaviors Picture this: you're building a booking platform. Product comes to you with two requests: **Request 1**: "We need a way to track alerts – missing documents on bookings, incomplete onboarding on users, expired contracts, failed payments. The system should automatically create and resolve these." **Request 2**: "Admins want to leave notes on things – bookings, user profiles, captioner profiles. You know, internal context that helps us manage accounts." The naive approach? Create separate tables for each combination: `booking_alerts`, `user_alerts`, `booking_notes`, `user_notes`, `captioner_profile_notes`... yeah, no thanks. The slightly better but still annoying approach? Manually add the same relationship code to every model. Copy, paste, hope you don't miss anything when you add the next entity type. **There's a better way.** ## The Solution: Composition with Type Discriminators The core idea: **composition** (mixins that add behaviors) + **type discriminators** (one table that references multiple entity types using a discriminator column). This pattern isn't SQLAlchemy-specific – Rails has it, Django has it, you can build it in any ORM. The key insight is separating the "what" (the behavior) from the "who" (the entity type). Here's how it works: create ONE table per behavior that can reference any entity type using a type discriminator, then use a mixin to compose that behavior into any model that needs it. We'll build two examples: 1. **Alerts** – system-generated warnings that get resolved 2. **Notes** – admin-created comments with audit trails Both use the same discriminator-based pattern, but configured differently for their specific needs. ### This Isn't About SQLAlchemy Before we dive into code, let's be clear: this is an **architectural pattern**, not a SQLAlchemy feature. The concepts are universal: - **Type discriminators**: A database design pattern where one table references multiple entity types using a discriminator column. Rails calls this `polymorphic: true`, Django calls it `GenericForeignKey`, and you can build it in any ORM or even raw SQL. - **Composition**: Using mixins to compose behaviors into classes. This is "composition over inheritance" in action. ## Example 1: System Alerts ### The Alert Model First, we need a table that can store alerts about different types of entities: ```python class AlertModel(BaseModel): __tablename__ = 'alerts' about_id: Mapped[str] # ID of the entity about_type: Mapped[AlertAboutType] # Type: BOOKING, USER, etc. alert_type: Mapped[AlertType] # Specific alert type resolved: Mapped[bool] # Is it resolved? resolved_at: Mapped[Optional[datetime]] __table_args__ = ( Index('ix_alerts_about_type_id', 'about_type', 'about_id'), ) def resolve(self): self.resolved = True self.resolved_at = datetime.now(UTC) ``` Notice the `about_id` + `about_type` combo? That's our type discriminator. It lets ONE table reference many different entity types by storing both the ID and the type of the entity. ### Composing the Alert Behavior Now here's where composition shines. We create a mixin that any model can use to compose in the alert behavior: ```python class AlertableMixin: @declared_attr def alerts(cls) -> Mapped[list[AlertModel]]: return relationship( 'AlertModel', primaryjoin=lambda: and_( AlertModel.about_type == literal( cls.__name__.upper().replace('MODEL', '') ), foreign(AlertModel.about_id) == cls.id, ), viewonly=True, lazy='raise', # Force explicit loading ) def create_alert(self, *, alert_type: AlertType) -> AlertModel: """Create a new alert for this entity""" return AlertModel( about_type=self.__class__.__name__.upper().replace('MODEL', ''), about_id=self.id, alert_type=alert_type, ) def unresolved_alerts(self) -> list[AlertModel]: """Filter already-loaded alerts to unresolved ones""" return [a for a in self.alerts if not a.resolved] def has_unresolved_alert(self, alert_type: AlertType) -> bool: """Check if a specific alert type exists and is unresolved""" return any( a for a in self.alerts if a.alert_type == alert_type and not a.resolved ) ``` **What's happening here?** - **Composition**: The mixin composes the alert behavior into any model that inherits it - **Discriminator-based relationship**: `@declared_attr` creates the relationship dynamically, using the type discriminator (`about_id` + `about_type`) - **Type extraction**: The `primaryjoin` extracts the entity type from the model name (`UserModel` → `USER`) – this is how we match alerts to the right entity - **Explicit loading**: `lazy='raise'` prevents N+1 queries – you MUST explicitly load alerts when you need them using `selectinload(BookingModel.alerts)` in your query. This prevents the common performance pitfall where loading 100 bookings triggers 100 additional database queries. - **Encapsulation**: Helper methods encapsulate common operations, keeping the behavior's API clean The mixin is a reusable component. Add it to any model, and that model gets alert functionality. That's composition in action. ### Composing Alerts into Models ```python class UserModel(BaseModel, AlertableMixin): __tablename__ = 'users' email: Mapped[str] # ... other fields class BookingModel(BaseModel, AlertableMixin): __tablename__ = 'bookings' status: Mapped[str] # ... other fields ``` Done. Both models now have alerts. ```python # Create an alert booking = await booking_repo.get_booking_by_id(booking_id) alert = booking.create_alert( alert_type=AlertType.MISSING_DOCUMENTS ) await session.commit() ``` ## Example 2: Admin Notes Now let's look at a different behavior with different requirements. Admins wanted to leave internal notes on various entities throughout the admin panel. ### The Note Model ```python class NoteModel(BaseModel): __tablename__ = 'notes' about_id: Mapped[str] = mapped_column(String, nullable=False) about_type: Mapped[NoteAboutType] = mapped_column(String, nullable=False) content: Mapped[str] = mapped_column(String, nullable=False) # Track who created the note admin_id: Mapped[str] = mapped_column(ForeignKey('admins.id')) ``` Same discriminator-based pattern, but with audit fields and a relationship to track who created the note. ### Composing the Note Behavior ```python class NoteableMixin: @declared_attr def notes(cls) -> Mapped[list[NoteModel]]: return relationship( 'NoteModel', primaryjoin=lambda: and_( NoteModel.about_type == literal( cls.__name__.upper().replace('MODEL', '') ), foreign(NoteModel.about_id) == cls.id, ), viewonly=True, lazy='select', # Auto-load when accessed order_by='desc(NoteModel.created_at)', # Most recent first ) def create_note(self, *, content: str, admin_id: str) -> NoteModel: return NoteModel( about_type=self.__class__.__name__.upper().replace('MODEL', ''), about_id=self.id, content=content, admin_id=admin_id, ) ``` **Notice the differences?** - `lazy='select'` instead of `lazy='raise'` – `lazy='select'` auto-loads notes when you access `booking.notes`, which is convenient but can cause N+1 queries if you're not careful (loading 100 bookings will trigger 100 separate queries). `lazy='raise'` forces you to explicitly load relationships (using `selectinload()` or `joinedload()`), preventing accidental N+1 queries. Choose based on your query patterns: use `'raise'` for performance-critical code, `'select'` for admin panels where convenience matters more. - `order_by='desc(NoteModel.created_at)'` – always get most recent notes first - `create_note()` requires `admin_id` for audit trail ### Composing Notes into Models ```python class BookingModel(BaseModel, AlertableMixin, NoteableMixin): __tablename__ = 'bookings' # ... fields class CaptionerProfileModel(BaseModel, NoteableMixin): __tablename__ = 'captioner_profiles' # ... fields ``` See that? `BookingModel` uses BOTH mixins. It composes both behaviors – alerts and notes – independently. This is the power of composition: each behavior is a separate, reusable component you can mix and match. ```python # Admin leaves a note booking = await booking_repo.get_booking_by_id(booking_id) note = booking.create_note( content="Customer called about missing invoice", admin_id=current_admin.id ) ``` ## Configuration Choices Matter Both mixins use the same discriminator-based pattern, but they're configured differently because they have different needs:
| Feature | Alerts | Notes |
| **lazy** | 'raise' | 'select' |
| **Why?** | Performance-critical queries with many entities. Forces explicit loading (selectinload/joinedload) to prevent N+1 queries. | Admin UI with fewer entities loaded. Convenience over performance. Still risks N+1 if loading many records. |
| **order_by** | None | desc(created_at) |
| **Why?** | Filter by resolved status instead | Always show most recent notes first |
| **Audit trail** | Just resolved_at | Full created_at, updated_at, admin relationship |
| **Why?** | System-generated, less important who resolved | Need to know who said what when |