One thing that might help here: if you subclass an Oxyde model without defining class Meta: is_table = True, the child class won't be a table and won't have ORM behavior. So you can inherit the fields and validation but without save()/delete(). Not exactly a Protocol-based approach, but it gives you a clean read-only DTO derived from the same model.
To expand a bit on this, what I'm thinking about is a modular monolith architecture. It's also a pragmatic approach where you don't need to split into separate (micro)services yet, but you still want things to be modular and able to split later if need be.
While things are still in the same monolith there's no point actually doing the serialise/deserialise step to enable integration between modules, so you can just have modules call each others services directly. Having the automatic DTO means in a service you could just do something like:
def get_all() -> Iterable[ModelDTO]:
for obj in Model.objects.as_dtos():
yield obj
This same service could then be used in the router which would perform the extra serialisation step which would, of course, still work fine on the very same DTOs.
I tend to find the approach of "clean" domain model (using e.g. attrs or dataclasses), SQLAlchemy for db persistence (in classic mapping mode), and serialisation (using e.g. cattrs) a more elegant architecture, as opposed to shipping serialisation and persistence around with every object. But I know people struggle with such a rigid up-front architecture and most prefer Django, so I'm always looking for a pragmatic middle ground.
The way I see it, having everything as Pydantic makes this natural. Your DB model, your request schema, your response DTO are all BaseModels. Converting between them is just model_dump() and model_validate(), or plain inheritance. No adapters, no mapping layers. So when you need to split things apart, it's straightforward rather than painful.
Yeah, but I'm afraid of a system like Django where not calling `.save()` in the wrong place is down to discipline and understanding. I want there to be some way to stop this happening that doesn't rely on a couple of maintainers keeping strict vigilance over the codebase.
Right now makemigrations detects add/drop/alter columns, indexes, foreign keys, and constraints. Rename is treated as drop + create, same as Django's default. Automatic rename detection is on the roadmap but not there yet. For now you'd edit the generated migration manually if you need a rename. Splitting a model into two would be detected as one dropped table and two new ones, so you'd also want to adjust that migration by hand to preserve data.
That's exactly why Oxyde has no lazy loading at all. If you don't call .join() or .prefetch(), related data simply won't be there. N+1 is impossible by design, not by discipline.
2. A converter still means maintaining two model systems. The point was to not have two in the first place.
3. MessagePack overhead is negligible compared to actual DB round-trips. And the Rust core isn't just SQL generation, it bundles the full driver stack (sqlx), pooling, and streaming, so you don't need asyncpg/aiosqlite as separate dependencies.
Right now it's CLI-based: 'oxyde migrate'. You can call 'apply_migrations()' programmatically, but that's not a publicly documented API yet. Good point though, worth adding.