Using Frozen, Slotted Dataclasses for Internal Service Messages in Python
An architecture note on using @dataclass(frozen=True, slots=True) as a minimal typed container for internal service messages: requirements, boundary validation, checks, and failure modes.
23 Jun 2026, 02:55 UTC

The problem
Internal services pass a lot of small messages between components: an event ID, a timestamp, a payload dict, maybe a source tag. The usual starting point is a plain dict, which quietly rots — keys get misspelled, timestamps arrive naive in one place and timezone-aware in another, and tests assert against magic strings. The fix most teams reach for is a small typed container. The takeaway: @dataclass(frozen=True, slots=True) (Python 3.10+) is the smallest design that gives you construction, equality, hashing, and readable logs for free — as long as you keep validation at the boundary and the class itself dumb.
Requirements
For a message type like this, the actual requirements are modest:
- Automatic
__init__,__repr__, and__eq__so tests and logs work without boilerplate. - Immutability after construction, so a message handed to three consumers can't be edited by the first one.
- Hashability, so messages can go into sets for deduplication or act as cache keys.
- Type annotations that survive at runtime for introspection and documentation.
Anything beyond that — schema versioning, cross-service serialization, coercion of untrusted input — is a different layer's job.
The smallest suitable design
from dataclasses import dataclass, field
from datetime import datetime
@dataclass(frozen=True, slots=True)
class EventMessage:
id: str
timestamp: datetime
payload: dict = field(default_factory=dict)
Three decisions matter here:
frozen=Truemakes instances immutable and hashable. Attemptingmsg.id = "x"raisesFrozenInstanceError.slots=True(3.10+) drops the per-instance__dict__, cutting memory and catching typos: assigning an undeclared attribute raisesAttributeError.field(default_factory=dict)is mandatory for mutable defaults. Writingpayload: dict = {}raisesValueErrorat class definition time — the dataclass module refuses shared mutable defaults, which is exactly the bug you want caught early.
Trust and data boundaries
The dataclass is a pure data holder. Validation of external data happens at the boundary, before or during construction — never scattered across consumers. The lightest hook is __post_init__, which the generated __init__ calls last:
@dataclass(frozen=True, slots=True)
class EventMessage:
id: str
timestamp: datetime
payload: dict = field(default_factory=dict)
def __post_init__(self):
if not self.id:
raise ValueError("id must be non-empty")
if self.timestamp.tzinfo is None:
raise ValueError("timestamp must be timezone-aware")
Keep this to cheap invariant checks (non-empty, aware datetime, allowed enum values). If validation grows — parsing raw JSON, coercing strings to datetimes, handling schema versions — move it into a factory function like EventMessage.from_wire(data) so the class stays readable. Note that frozen=True does not deep-freeze: msg.payload["k"] = "v" still mutates the dict. If that matters, store a MappingProxyType or an immutable mapping in __post_init__ via object.__setattr__.
Operational checks
Verify the behavior once, in a REPL or a unit test (Python 3.10+; examples assume 3.11):
EventMessage.__slots__returns a tuple of field names, confirming slots are active.- Construct two instances with identical fields and assert
a == bandhash(a) == hash(b)— this confirms they work as set members and dict keys. - Instantiate with
timestamp=datetime.now()(naive) and confirm theValueErrorfrom__post_init__fires. typing.get_type_hints(EventMessage)returns the field annotations, confirming they survive for runtime introspection (e.g., a logging middleware that serializes by declared fields).- Attempt
msg.unknown = 1and confirmAttributeError.
The generated __repr__ (EventMessage(id='...', timestamp=..., payload=...)) is usually good enough to drop straight into structured logs without a custom formatter.
Failure modes
- Omitting
frozen=True: the class becomes mutable and unhashable. The common symptom is aTypeError: unhashable typethe first time someone deduplicates messages with a set. - Mutable default without a factory: caught at class definition, thankfully — but only if you run the module. It will not slip past import.
- Slots and inheritance: if a parent class lacks
__slots__, subclasses silently regain__dict__and lose the memory and typo-checking benefits. Mixins that set undeclared attributes will also break withAttributeError. - Shallow immutability: nested mutable fields (the payload dict) remain mutable. Treat
frozenas protecting the reference, not the contents.
When this design stops fitting
Reconsider when any of these become true: you need coercion and rich validation of untrusted external data (a validation library earns its keep there); messages cross process or network boundaries and need versioned serialization; or consumers legitimately need dynamic attributes. The memory savings from slots are real but modest — adopt them for the typo-catching and clarity, not as a performance strategy. For a bounded internal message type, though, frozen-plus-slots is the smallest thing that behaves correctly, and it costs one decorator line.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.