Architecture

Serializers

Read/write serializer pattern for transforming models to/from API

ZineCore2 uses separate read and write serializers for each profile. This pattern provides different representations for input (write) vs. output (read) operations.

The Problem

API clients have different needs when reading vs. writing data:

Reading (GET):

  • Want full nested objects (e.g., agent details inside zine records)
  • Need resolved relationships
  • Prefer human-readable data

Writing (POST/PUT/PATCH):

  • Want to provide simple ID references
  • Don't want to send full nested objects
  • Need clear validation errors

A single serializer can't serve both needs well.


The Solution: Read/Write Split

Each profile has two serializers:

  • Read Serializer — For GET requests, returns fully resolved objects
  • Write Serializer — For POST/PUT/PATCH, accepts simple ID references

ViewSet Integration

# catalog/views.py
class ZineViewSet(viewsets.ModelViewSet):
    queryset = Zine.objects.all()
    lookup_field = 'zine_id'  # Use external ID

    def get_serializer_class(self):
        """Return appropriate serializer based on action"""
        if self.action in ['create', 'update', 'partial_update']:
            return ZineWriteSerializer
        return ZineReadSerializer

Automatic switching:

  • GET /api/zines/ → Uses ZineReadSerializer
  • POST /api/zines/ → Uses ZineWriteSerializer
  • PATCH /api/zines/zine_001/ → Uses ZineWriteSerializer
  • Response always uses ZineReadSerializer

Optional ID Fields

All profile serializers support optional ID fields that are auto-generated if omitted:

  • ZineCore2: zine_id (already optional)
  • AgentCore2: agent_id (made optional)
  • RepoCore2: repo_id (made optional)
  • HoldingCore2: holding_id (made optional)

Example: Create an agent without specifying agent_id:

{
  "kind": "person",
  "display_name": "Jane Doe",
  "public": true
}

Response includes auto-generated ID:

{
  "agent_id": "agent_a3f8d9e2b1c4",
  "kind": "person",
  "display_name": "Jane Doe",
  "public": true,
  "created_at": "2024-02-25T10:30:00Z"
}

This simplifies bulk imports where you don't want to manually assign IDs.


Vocabulary Code Acceptance

Write serializers use SlugRelatedField to accept vocabulary codes instead of primary keys for kind fields:

Before (old approach):

{
  "kind": 1  // Database primary key - brittle!
}

After (current approach):

{
  "kind": "person"  // Vocabulary code - human-readable!
}

This makes API requests more readable and eliminates dependency on database primary keys.

Applies to:

  • AgentWriteSerializer.kind - accepts "person", "organization", "collective"
  • RepositoryWriteSerializer.kind - accepts "library", "archive", "zine-library", "distro"
  • HoldingWriteSerializer.access_status - accepts vocabulary codes for access status

Zine Serializers

ZineWriteSerializer (Input)

Accepts simple ID strings for relationships:

# catalog/serializers.py
class ZineWriteSerializer(serializers.ModelSerializer):
    """Write serializer - accepts agent IDs as strings"""

    zine_id = serializers.CharField(
        max_length=255,
        required=False,
        help_text="Optional. Auto-generated if omitted."
    )

    creator = serializers.ListField(
        child=serializers.CharField(max_length=255),
        help_text="Agent IDs from AgentCore2"
    )

    contributor = serializers.ListField(
        child=serializers.CharField(max_length=255),
        required=False,
        default=list
    )

    publisher = serializers.ListField(
        child=serializers.CharField(max_length=255),
        required=False,
        default=list
    )

    subject = serializers.ListField(
        child=serializers.CharField(max_length=100),
        help_text="Subject codes from vocabulary"
    )

    genre = serializers.ListField(
        child=serializers.CharField(max_length=100),
        help_text="Genre codes from vocabulary"
    )

    class Meta:
        model = Zine
        fields = [
            'zine_id', 'title', 'series_title', 'issue_designation',
            'creator', 'contributor', 'publisher', 'subject', 'genre',
            'date', 'language', 'rights', 'abstract', 'format',
            # ... all fields
        ]

    def create(self, validated_data):
        """Auto-generate zine_id if not provided"""
        if 'zine_id' not in validated_data or not validated_data['zine_id']:
            # Generate unique zine_id
            import uuid
            validated_data['zine_id'] = f"zine_{uuid.uuid4().hex[:12]}"
        return super().create(validated_data)

    def validate_creator(self, value):
        """Ensure all creator agent IDs exist"""
        from agents.models import Agent

        for agent_id in value:
            if not Agent.objects.filter(agent_id=agent_id).exists():
                raise serializers.ValidationError(
                    f"Agent '{agent_id}' does not exist"
                )
        return value

    def validate_subject(self, value):
        """Ensure all subject codes are valid"""
        from catalog.models import Subject

        valid_codes = set(Subject.objects.values_list('code', flat=True))
        invalid = [code for code in value if code not in valid_codes]

        if invalid:
            raise serializers.ValidationError(
                f"Invalid subject codes: {', '.join(invalid)}"
            )
        return value

Example POST request (with explicit ID):

{
  "zine_id": "zine_mutate_3_1st",
  "title": "Mutate Zine #3",
  "creator": ["agent_judith_arcana"],
  "subject": ["feminism", "reproductive-rights"],
  "genre": ["personal-zine"],
  "date": ["2024"],
  "language": ["en"],
  "rights": ["cc-by-4.0"]
}

Example POST request (auto-generated ID):

{
  "title": "Feminist Killjoy #1",
  "creator": ["agent_sara_ahmed"],
  "subject": ["feminism"],
  "genre": ["personal-zine"],
  "date": ["2024"],
  "language": ["en"],
  "rights": ["cc-by-4.0"]
}

Response includes auto-generated zine_id:

{
  "zine_id": "zine_7f3a8d9e2b1c",
  "title": "Feminist Killjoy #1",
  ...
}

Simple! Just provide agent IDs as strings, and optionally omit zine_id to auto-generate.

ZineReadSerializer (Output)

Returns fully resolved agent objects:

# catalog/serializers.py
class ZineReadSerializer(serializers.ModelSerializer):
    """Read serializer - resolves agent IDs to full objects"""

    creator = AgentReadSerializer(
        many=True,
        source='creator_agents'
    )

    contributor = AgentReadSerializer(
        many=True,
        source='contributor_agents',
        required=False
    )

    publisher = AgentReadSerializer(
        many=True,
        source='publisher_agents',
        required=False
    )

    subject = SubjectSerializer(
        many=True,
        source='subject_objects'
    )

    genre = GenreSerializer(
        many=True,
        source='genre_objects'
    )

    class Meta:
        model = Zine
        fields = [
            'zine_id', 'title', 'series_title', 'issue_designation',
            'creator', 'contributor', 'publisher', 'subject', 'genre',
            'date', 'language', 'rights', 'abstract', 'format',
            'created_at', 'updated_at',
            # ... all fields
        ]

Example GET response:

{
  "zine_id": "zine_mutate_3_1st",
  "title": "Mutate Zine #3",
  "creator": [
    {
      "agent_id": "agent_judith_arcana",
      "display_name": "Judith Arcana",
      "kind": "Person",
      "pronouns": ["she/her"]
    }
  ],
  "subject": [
    {
      "code": "feminism",
      "label": "Feminism",
      "uri": "https://zinecore.org/v2/subjects#feminism"
    },
    {
      "code": "reproductive-rights",
      "label": "Reproductive Rights",
      "uri": "https://zinecore.org/v2/subjects#reproductive-rights"
    }
  ],
  "genre": [
    {
      "code": "personal-zine",
      "label": "Personal Zine",
      "uri": "https://zinecore.org/v2/genres#personal-zine"
    }
  ],
  "created_at": "2024-02-25T10:30:00Z",
  "updated_at": "2024-02-25T10:30:00Z"
}

Much richer! Full nested objects with all details.


Model Properties for Resolution

The model needs properties to resolve IDs to objects:

# catalog/models.py
class Zine(TimestampedModel):
    # Stored fields (ArrayField of IDs)
    creator = ArrayField(models.CharField(max_length=255))
    subject = ArrayField(models.CharField(max_length=100))
    genre = ArrayField(models.CharField(max_length=100))

    # Properties for read serializer
    @property
    def creator_agents(self):
        """Resolve creator IDs to Agent objects"""
        from agents.models import Agent
        return Agent.objects.filter(agent_id__in=self.creator)

    @property
    def subject_objects(self):
        """Resolve subject codes to Subject objects"""
        return Subject.objects.filter(code__in=self.subject)

    @property
    def genre_objects(self):
        """Resolve genre codes to Genre objects"""
        return Genre.objects.filter(code__in=self.genre)

How it works:

  1. Database stores arrays of IDs: ["agent_judith_arcana"]
  2. Read serializer calls source='creator_agents'
  3. Model property queries database for matching Agent records
  4. Serializer nests full agent objects in response

Agent Serializers

AgentWriteSerializer (Input)

Accepts vocabulary codes for the kind field instead of primary keys:

# agents/serializers.py
class AgentWriteSerializer(serializers.ModelSerializer):
    """Write serializer - accepts vocabulary codes for kind field"""

    kind = serializers.SlugRelatedField(
        slug_field='code',
        queryset=AgentKind.objects.all(),
        help_text="Agent kind code (e.g., 'person', 'organization', 'collective')"
    )

    agent_id = serializers.CharField(
        max_length=255,
        required=False,
        help_text="Optional. Auto-generated if omitted."
    )

    class Meta:
        model = Agent
        fields = [
            'agent_id', 'kind', 'display_name', 'public',
            'legal_name', 'alternative_names', 'pronouns',
            'biography', 'scope_note', 'roles',
            'orcid', 'wikidata', 'website', 'email',
            # ... all fields
        ]

    def create(self, validated_data):
        """Auto-generate agent_id if not provided"""
        if 'agent_id' not in validated_data or not validated_data['agent_id']:
            # Generate unique agent_id
            import uuid
            validated_data['agent_id'] = f"agent_{uuid.uuid4().hex[:12]}"
        return super().create(validated_data)

Example POST request:

{
  "kind": "person",
  "display_name": "Judith Arcana",
  "public": true,
  "pronouns": ["she/her"],
  "biography": "Writer and activist focused on reproductive rights."
}

Note:

  • kind accepts vocabulary codes ("person", "organization", "collective") instead of database primary keys
  • agent_id is optional and will be auto-generated if omitted
  • The SlugRelatedField automatically validates that the code exists in the AgentKind vocabulary

AgentReadSerializer (Output)

class AgentReadSerializer(serializers.ModelSerializer):
    """Read serializer - includes computed fields"""

    kind_details = serializers.SerializerMethodField()

    class Meta:
        model = Agent
        fields = [
            'agent_id', 'kind', 'kind_details', 'display_name',
            'public', 'pronouns', 'biography', 'website',
            'created_at', 'updated_at',
            # ... fields based on privacy
        ]

    def get_kind_details(self, obj):
        """Resolve kind code to full vocabulary term"""
        from agents.models import AgentKind

        kind = AgentKind.objects.filter(code=obj.kind).first()
        if kind:
            return {
                'code': kind.code,
                'label': kind.label,
                'uri': kind.uri
            }
        return None

    def to_representation(self, instance):
        """Hide private fields if agent is not public"""
        data = super().to_representation(instance)

        if not instance.public:
            # Remove private fields
            data.pop('legal_name', None)
            data.pop('email', None)
            data.pop('phone', None)

        return data

Privacy handling:

If Agent.public = False, the read serializer automatically removes sensitive fields.


Holding Serializers

Holdings reference both zines and repositories.

HoldingWriteSerializer (Input)

# holdings/serializers.py
class HoldingWriteSerializer(serializers.ModelSerializer):
    """Accept zine_id and repo_id as strings"""

    holding_id = serializers.CharField(
        max_length=255,
        required=False,
        help_text="Optional. Auto-generated if omitted."
    )

    zine_id = serializers.CharField(max_length=255)
    repository_id = serializers.CharField(max_length=255)

    class Meta:
        model = Holding
        fields = [
            'holding_id', 'zine_id', 'repository_id',
            'call_number', 'location', 'access_status',
            'condition', 'copy_count', 'barcode',
            'digital_available', 'digital_url', 'notes'
        ]

    def validate(self, data):
        """Ensure zine and repository exist"""
        from catalog.models import Zine
        from repositories.models import Repository

        # Validate zine exists
        zine_id = data.get('zine_id')
        if not Zine.objects.filter(zine_id=zine_id).exists():
            raise serializers.ValidationError({
                'zine_id': f"Zine '{zine_id}' does not exist"
            })

        # Validate repository exists
        repo_id = data.get('repository_id')
        if not Repository.objects.filter(repo_id=repo_id).exists():
            raise serializers.ValidationError({
                'repository_id': f"Repository '{repo_id}' does not exist"
            })

        return data

    def create(self, validated_data):
        """Resolve IDs to foreign keys and auto-generate holding_id if needed"""
        from catalog.models import Zine
        from repositories.models import Repository

        # Auto-generate holding_id if not provided
        if 'holding_id' not in validated_data or not validated_data['holding_id']:
            import uuid
            validated_data['holding_id'] = f"holding_{uuid.uuid4().hex[:12]}"

        # Get actual model instances
        zine = Zine.objects.get(zine_id=validated_data.pop('zine_id'))
        repo = Repository.objects.get(repo_id=validated_data.pop('repository_id'))

        # Create holding with FK references
        return Holding.objects.create(
            zine=zine,
            repository=repo,
            **validated_data
        )

HoldingReadSerializer (Output)

class HoldingReadSerializer(serializers.ModelSerializer):
    """Return full nested zine and repository objects"""

    zine = ZineReadSerializer()
    repository = RepositoryReadSerializer()

    class Meta:
        model = Holding
        fields = [
            'holding_id', 'zine', 'repository',
            'call_number', 'location', 'access_status',
            'condition', 'copy_count', 'digital_available',
            'digital_url', 'notes', 'created_at', 'updated_at'
        ]

Example response:

{
  "holding_id": "holding_001",
  "zine": {
    "zine_id": "zine_mutate_3_1st",
    "title": "Mutate Zine #3",
    "creator": [...]
  },
  "repository": {
    "repo_id": "repo_barnard",
    "repository_name": "Barnard Zine Library",
    "city": "New York",
    "country": "US"
  },
  "location": "Shelf A-12",
  "condition": "Excellent"
}

Repository Serializers

Repositories use SlugRelatedField for the kind field:

# repositories/serializers.py
class RepositoryWriteSerializer(serializers.ModelSerializer):
    """Write serializer - accepts vocabulary codes for kind field"""

    kind = serializers.SlugRelatedField(
        slug_field='code',
        queryset=RepositoryKind.objects.all(),
        help_text="Repository kind code (e.g., 'library', 'archive', 'zine-library', 'distro')"
    )

    repo_id = serializers.CharField(
        max_length=255,
        required=False,
        help_text="Optional. Auto-generated if omitted."
    )

    class Meta:
        model = Repository
        fields = [
            'repo_id', 'repository_name', 'kind',
            'country', 'description', 'city', 'region',
            'website', 'email', 'status',
            # ... all fields
        ]

    def create(self, validated_data):
        """Auto-generate repo_id if not provided"""
        if 'repo_id' not in validated_data or not validated_data['repo_id']:
            # Generate unique repo_id
            import uuid
            validated_data['repo_id'] = f"repo_{uuid.uuid4().hex[:12]}"
        return super().create(validated_data)

class RepositoryReadSerializer(serializers.ModelSerializer):
    """Read serializer - includes timestamps and resolved kind"""

    kind_details = serializers.SerializerMethodField()

    class Meta:
        model = Repository
        fields = [
            'repo_id', 'repository_name', 'kind', 'kind_details',
            'country', 'description', 'city', 'region',
            'website', 'email', 'status',
            'created_at', 'updated_at'
        ]

    def get_kind_details(self, obj):
        """Resolve kind code to full vocabulary term"""
        from repositories.models import RepositoryKind

        kind = RepositoryKind.objects.filter(code=obj.kind).first()
        if kind:
            return {
                'code': kind.code,
                'label': kind.label,
                'uri': kind.uri
            }
        return None

Example POST request:

{
  "repository_name": "Barnard Zine Library",
  "kind": "zine-library",
  "city": "New York",
  "region": "NY",
  "country": "US",
  "website": "https://zines.barnard.edu"
}

Note:

  • kind accepts vocabulary codes ("library", "archive", "zine-library", "distro") instead of primary keys
  • repo_id is optional and will be auto-generated if omitted

Vocabulary Serializers

Vocabularies are read-only (use fixtures to load):

# catalog/serializers.py
class SubjectSerializer(serializers.ModelSerializer):
    class Meta:
        model = Subject
        fields = ['code', 'label', 'definition', 'uri']

class GenreSerializer(serializers.ModelSerializer):
    class Meta:
        model = Genre
        fields = ['code', 'label', 'definition', 'uri']

No write serializers — vocabularies loaded via management commands.


Validation Patterns

Field-Level Validation

Validate individual fields:

def validate_zine_id(self, value):
    """Ensure ID follows naming convention"""
    if not value.startswith('zine_'):
        raise serializers.ValidationError(
            "zine_id must start with 'zine_'"
        )
    return value

Object-Level Validation

Validate across multiple fields:

def validate(self, data):
    """Cross-field validation"""
    if data.get('digital_available') and not data.get('digital_url'):
        raise serializers.ValidationError({
            'digital_url': 'Required when digital_available is true'
        })
    return data

Custom Validation Logic

def validate_date(self, value):
    """Ensure dates are ISO 8601"""
    import re

    iso_pattern = r'^\d{4}(-\d{2}(-\d{2})?)?$'
    for date_str in value:
        if not re.match(iso_pattern, date_str):
            raise serializers.ValidationError(
                f"Date '{date_str}' must be ISO 8601 (YYYY, YYYY-MM, or YYYY-MM-DD)"
            )
    return value

Benefits of This Pattern

1. Simple Write Operations

Clients only need to provide IDs:

curl -X POST http://localhost:8000/api/zines/ \
  -d '{"title": "My Zine", "creator": ["agent_001"]}'

No need to send full agent objects, and zine_id is auto-generated if omitted.

2. Rich Read Responses

Clients get fully resolved data:

{
  "creator": [
    {"agent_id": "agent_001", "display_name": "Jane Doe"}
  ]
}

No need for additional requests.

3. Clear Validation

Validation errors are specific:

{
  "creator": ["Agent 'agent_invalid' does not exist"],
  "subject": ["Invalid subject codes: invalid-code"]
}

4. Performance Optimization

Read serializer uses select_related() and prefetch_related() to avoid N+1 queries.

5. Flexibility

Different serializers can have different fields:

  • Write serializer: Only writable fields, optional IDs, vocabulary codes
  • Read serializer: Include computed fields, timestamps, resolved objects

6. Developer-Friendly Vocabulary Codes

Using SlugRelatedField for kind fields means:

  • Human-readable: "kind": "person" instead of "kind": 1
  • Database-independent: Codes remain stable even if primary keys change
  • Self-documenting: Clear what values are valid
  • Import-friendly: Easier to prepare bulk import data

7. Optional Auto-Generated IDs

All profiles support optional ID fields:

  • Simplifies bulk imports: No need to generate IDs manually
  • Prevents collisions: System ensures uniqueness
  • Flexible: Provide explicit IDs when needed, omit when not

ViewSet Implementation

Complete example showing serializer switching:

# catalog/views.py
from rest_framework import viewsets
from .models import Zine
from .serializers import ZineReadSerializer, ZineWriteSerializer

class ZineViewSet(viewsets.ModelViewSet):
    queryset = Zine.objects.all()
    lookup_field = 'zine_id'

    def get_serializer_class(self):
        """Return serializer based on action"""
        if self.action in ['create', 'update', 'partial_update']:
            return ZineWriteSerializer
        return ZineReadSerializer

    def get_queryset(self):
        """Optimize queries for read operations"""
        queryset = super().get_queryset()

        if self.action in ['list', 'retrieve']:
            # Prefetch related objects to avoid N+1
            queryset = queryset.prefetch_related(
                'creator_agents',
                'subject_objects',
                'genre_objects'
            )

        return queryset

Performance:

  • Write operations: No prefetching (not needed)
  • Read operations: Prefetch all relationships (avoids N+1 queries)

Error Handling

Serializers return detailed validation errors:

Missing Required Field

Request:

{
  "zine_id": "zine_001",
  "title": "My Zine"
  // Missing required fields
}

Response (400 Bad Request):

{
  "creator": ["This field is required."],
  "subject": ["This field is required."],
  "genre": ["This field is required."],
  "date": ["This field is required."],
  "language": ["This field is required."],
  "rights": ["This field is required."]
}

Invalid Foreign Key Reference

Request:

{
  "zine_id": "zine_001",
  "creator": ["agent_nonexistent"],
  "subject": ["feminism"],
  "genre": ["personal-zine"],
  "date": ["2024"],
  "language": ["en"],
  "rights": ["cc-by-4.0"]
}

Response (400 Bad Request):

{
  "creator": ["Agent 'agent_nonexistent' does not exist"]
}

Invalid Vocabulary Code

Request:

{
  "subject": ["invalid-subject"]
}

Response (400 Bad Request):

{
  "subject": ["Invalid subject codes: invalid-subject"]
}

Next Steps

Understanding serializers? Continue to External Identifiers to learn about the dual identifier pattern.
Copyright ©2026 ZineCore2 Contributors,