The Model Class

A class extending Model becomes a domain object with built-in serialization, validation, schema generation, and factory methods. A Model corresponds to the Entity concept from Domain-Driven Design — it is persisted in the database and exchanged between services. Unlike Python’s standard dataclass, a Model supports deferred validation, query DSL integration, automatic field generation, and MongoDB persistence.

Warning

This section covers the Model and its features in depth. For a quick overview, visit the How does it work? section first.

Features of a Model

Introduction to the Model Class

Note

All examples below can be followed in Python’s interactive console using the imports shown here:

from datetime import datetime
from typing import Annotated
from pydantic import Field
from appkernel import (
    Model, MongoRepository,
    Required, Generator, Converter, Default, Validators, Marshal,
    MongoUniqueIndex, Email, NotEmpty, Past,
    create_uuid_generator, date_now_generator, content_hasher,
)
from appkernel.generators import TimestampMarshaller

The following example showcases the most notable features of a Model class:

class User(Model, MongoRepository):
    id: Annotated[str | None, Required(), Generator(create_uuid_generator('U'))] = None
    name: Annotated[str | None, Required(), MongoUniqueIndex()] = None
    email: Annotated[str | None, Validators(Email), MongoUniqueIndex()] = None
    password: Annotated[str | None, Validators(NotEmpty), Converter(content_hasher()), Field(exclude=True)] = None
    roles: Annotated[list[str] | None, Default(['Login'])] = None
    registration: Annotated[datetime | None, Validators(Past), Generator(date_now_generator)] = None


user = User(name='some user', email='some@acme.com', password='some pass')
user.save()
print(user.dumps(pretty_print=True))

This produces the following output:

{
    "email": "some@acme.com",
    "id": "U943a5699-fa7c-4431-949d-3763ce92b847",
    "name": "some user",
    "registration": "2018-06-03T13:32:51.636770",
    "roles": [
        "Login"
    ]
}

Here is what happened:

  • id: auto-generated on save. The UUID prefix (‘U’) makes it immediately clear which collection the document belongs to, which simplifies debugging;

  • name: required and indexed with a unique constraint — duplicate names will be rejected;

  • email: validated against a regular expression (must contain ‘@’ and ‘.’) and also unique in the collection;

  • password: converted to a hashed value on save. Field(exclude=True) excludes it from all JSON and wire-format output;

  • roles: assigned the default value ['Login'] automatically on validation if not provided;

  • registration: set to the current date and time on save;

Note

Models use a deferred validation pattern: all fields default to None and validation runs explicitly when finalise_and_validate() is called, or implicitly when save() or dumps() is invoked.

Appending items to a list field is straightforward:

user.append_to(roles=['Admin', 'Support'])
print(user.dumps(pretty_print=True))

{
    "email": "some@acme.com",
    "id": "U943a5699-fa7c-4431-949d-3763ce92b847",
    "name": "some user",
    "registration": "2018-06-03T13:32:51.636770",
    "roles": [
        "Login",
        "Admin",
        "Support"
    ]
}

Removing an item from a list is equally simple:

user.remove_from(roles='Admin')

And the built-in string representation gives you a compact view:

print(user)
<User> {"email": "some@acme.com", "id": "U943a5699-fa7c-4431-949d-3763ce92b847", "name": "some user", "registration": "2018-06-03T13:32:51.636770", "roles": ["Login", "Support"]}

Extra attributes can also be set dynamically:

user.enabled = True
print(user.dumps(pretty_print=True))
{
    "email": "some@acme.com",
    "enabled": true,
    "id": "U943a5699-fa7c-4431-949d-3763ce92b847",
    "name": "some user",
    "registration": "2018-06-03T13:32:51.636770",
    "roles": [
        "Login",
        "Support"
    ]
}

What happens when we create an invalid User?:

incomplete_user = User()
incomplete_user.finalise_and_validate()

This raises:

PropertyRequiredException: The property [name] on class [User] is required.

Extensible Data Validation

Validation is controlled by markers placed in the Annotated[] metadata of each field. The Required() marker checks that a field is not None at validation time. The Validators() marker accepts one or more validator instances or classes.

Built-in validators

NotEmpty — checks that the value is defined and non-empty:

name: Annotated[str | None, Validators(NotEmpty)] = None

Regexp — checks that the value matches a regular expression:

code: Annotated[str | None, Required(), Validators(Regexp('^[0-9]+$'))] = None

Email — a Regexp specialisation with a built-in e-mail pattern:

email: Annotated[str | None, Validators(Email)] = None

Min and Max — numeric range validation:

sequence: Annotated[int | None, Validators(Min(1), Max(100))] = None

Past and Future — temporal validation:

updated: Annotated[datetime | None, Validators(Past)] = None

Unique — adds a unique constraint and marks the field in the JSON schema:

username: Annotated[str | None, Validators(Unique)] = None

Model-level validation

For validation logic that spans multiple fields, implement a validate() method:

class Payment(Model):
    method: Annotated[PaymentMethod | None, Required()] = None
    customer_id: Annotated[str | None, Required(), Validators(NotEmpty)] = None
    customer_secret: Annotated[str | None, Required(), Validators(NotEmpty)] = None

    def validate(self):
        if self.method in (PaymentMethod.MASTER, PaymentMethod.VISA):
            if len(self.customer_id) < 16 or len(self.customer_secret) < 3:
                raise ValidationException('Card number must be 16 characters and CVC 3.')
        elif self.method in (PaymentMethod.PAYPAL, PaymentMethod.DIRECT_DEBIT):
            if len(self.customer_id) < 22:
                raise ValidationException('IBAN must be at least 22 characters.')

Note

The validate() method should not return a value — it raises ValidationException when conditions are not met.

Note

Use the _() function for translatable validation error messages. Import it at the top of your module and wrap every user-facing string:

from gettext import gettext as _

class Payment(Model):
    method: Annotated[PaymentMethod | None, Required()] = None
    customer_id: Annotated[str | None, Required(), Validators(NotEmpty)] = None
    customer_secret: Annotated[str | None, Required(), Validators(NotEmpty)] = None

    def validate(self):
        if self.method in (PaymentMethod.MASTER, PaymentMethod.VISA):
            if len(self.customer_id) < 16 or len(self.customer_secret) < 3:
                raise ValidationException(
                    _('Card number must be 16 characters and CVC 3.')
                )
        elif self.method in (PaymentMethod.PAYPAL, PaymentMethod.DIRECT_DEBIT):
            if len(self.customer_id) < 22:
                raise ValidationException(
                    _('IBAN must be at least 22 characters.')
                )

AppKernel’s LocaleMiddleware sets the active locale from the Accept-Language request header before validation runs, so the translated string is automatically chosen for each caller. See I18n (Internationalisation) for the full pybabel extract / init / compile workflow.

Writing a custom validator

Extend Validator and implement the validate method:

class CustomValidator(Validator):
    def __init__(self, value):
        super().__init__('CustomValidator', value)

    def validate(self, param_name, param_value):
        if self.value != param_value:
            raise ValidationException(
                self.type, param_value,
                _('Property %(pname)s cannot be validated against %(value)s',
                  pname=param_name, value=self.value))

For validators that need access to the whole object, implement validate_objects:

class CreditCardValidator(Validator):
    def __init__(self):
        super().__init__('CreditCardValidator')

    def validate_objects(self, parameter_name: str, instance_parameters: dict):
        card_number = instance_parameters.get(parameter_name)
        if instance_parameters.get('payment_method') == 'VISA':
            self.__visa_luhn_check(card_number)
        else:
            self.__mastercard_luhn_check(card_number)

Default Values and Generators

Fields can be automatically populated at validation time using the Generator() marker, or assigned a static default using the Default() marker.

The Generator() marker wraps any callable that returns the desired value:

id: Annotated[str | None, Required(), Generator(create_uuid_generator('U'))] = None

The field will receive a generated value when finalise_and_validate() or save() is called — but only if the field is currently None. Providing a value explicitly always takes precedence.

Writing a custom generator is straightforward — any zero-argument callable works:

def uuid_generator(prefix=None):
    def generate_id():
        return f'{prefix}{uuid.uuid4()}'
    return generate_id

Prefixed IDs make it easy to identify the owning collection from the ID alone (e.g. ‘U’ for User, ‘CT’ for Customer).

Built-in generators

UUID Generator — generates a globally unique ID, optionally prefixed:

id: Annotated[str | None, Generator(create_uuid_generator('U'))] = None

Date generator — captures the date-time at the moment of finalisation:

registration: Annotated[datetime | None, Generator(date_now_generator)] = None

Current user generator — records the authenticated user, useful for auditing:

owner: Annotated[str | None, Generator(current_user_generator)] = None

Converters

Converters transform an existing field value at validation time. Common use-cases include:

  • hashing passwords before saving;

  • encrypting sensitive data;

  • normalising text (e.g. lower-casing an e-mail address);

Use the Converter() marker with any function that accepts the current value and returns the transformed value. For one-way converters (like password hashing), the function simply returns the hashed value and the original is discarded:

password: Annotated[str | None, Required(), Validators(NotEmpty), Converter(content_hasher()), Field(exclude=True)] = None

The built-in hasher:

def content_hasher(rounds=20000, salt_size=16):
    def hash_content(content):
        if content.startswith('$pbkdf2-sha256'):
            return content
        return pbkdf2_sha256.encrypt(content, rounds=rounds, salt_size=salt_size)
    return hash_content

Dict and Json Converters

All Models can be serialised to and from dict or JSON (the wire format).

Writing JSON:

user.dumps()

The dumps() method accepts two optional parameters:

  • validate (default True): runs field validation and generators before serialising;

  • pretty_print (default False): produces indented output;

Example:

print(user.dumps(pretty_print=True))
{
    "email": "some@acme.com",
    "id": "Uf112dc8a-d75e-405c-ba8f-c15d1bf438f9",
    "name": "some user",
    "registration": "2018-06-03T17:39:54.125991",
    "roles": [
        "Login"
    ]
}

The password field is absent because Field(exclude=True) excludes it from all serialised representations.

To serialise to a Python dict:

User.to_dict(user)

Pass convert_id=True to rename the id field to _id for low-level MongoDB persistence.

To deserialise from a dict:

User.from_dict(some_dict_object)

Marshallers

A marshaller translates a field between its in-memory representation and its wire format. This is useful when you want to store or transmit a value in a different format than what your code works with.

Timestamp marshaller

The TimestampMarshaller converts a datetime to a Unix timestamp (float) on write, and back to datetime on read:

class User(Model, MongoRepository):
    last_login: Annotated[datetime | None, Marshal(TimestampMarshaller)] = None

Date-to-datetime marshaller

MongoDB does not support the bare date type — only datetime. Use MongoDateTimeMarshaller to convert automatically:

class Application(Model, MongoRepository):
    id: Annotated[str | None, Required(), Generator(create_uuid_generator())] = None
    application_date: Annotated[date | None, Required(), Marshal(MongoDateTimeMarshaller)] = None

Writing a custom marshaller

Extend Marshaller and implement both conversion directions:

class MyMarshaller(Marshaller):
    def to_wireformat(self, instance_value):
        # Return the value to store/transmit
        ...

    def from_wire_format(self, wire_value):
        # Return the in-memory value
        ...

JSON Schema

Generate a JSON Schema for validation or UI purposes:

User.get_json_schema()

Pass additional_properties=False to disallow any properties not declared on the class. Pass mongo_compatibility=True when using the schema as a MongoDB document validator, since Mongo handles dates and some other types differently.

Meta-data generator

In addition to standard JSON Schema, AppKernel provides a proprietary metadata format optimised for frontend UI generation:

print(json.dumps(User.get_parameter_spec(), indent=4))
{
    "name": {
        "required": true,
        "type": "str",
        "label": "User.name"
    },
    "roles": {
        "default_value": ["Login"],
        "required": false,
        "type": "list",
        "sub_type": "str",
        "label": "User.roles"
    },
    "email": {
        "validators": [{"type": "Email"}],
        "required": false,
        "type": "str",
        "label": "User.email"
    },
    "registration": {
        "validators": [{"type": "Past"}],
        "required": false,
        "type": "datetime",
        "label": "User.registration"
    },
    "password": {
        "validators": [{"type": "NotEmpty"}],
        "required": false,
        "type": "str",
        "label": "User.password"
    },
    "id": {
        "required": true,
        "type": "str",
        "label": "User.id"
    }
}