Thank you for your comment Rafał Kondziela , your points are absolutely valid, and it also means I didn’t explain my position clearly enough. Let me expand on it. ORM is not "bad". It’s a widely accepted but deeply wrong idea about how we should deal with a persistence layer. Maintainability You mentioned raw SQL being a nightmare in large projects. I’m not sure that's accurate unless we compare the actual maintenance cost. If anything, certain patterns become far more painful with an ORM because you inevitably end up fighting against its default state tracking and session mechanics. You’re not simplifying your system, you’re wrestling with an abstraction that doesn’t want to let go. As for schema changes: renaming a column used in dozens of queries usually looks as trivial as: SELECT u.email AS superid FROM users AS u A project-wide search-and-replace like u.email → u.superid does the job, and your tests stay green. If they don’t — great, that’s exactly why tests exist. Adding a column? You’d revisit queries, models and DTOs anyway. Add missing parts, amend tests or write new ones. Migrations and validation This heavily depends on the driver. For example, asyncpg handles booleans and other primitive types correctly out of the box. More complex types (like JSONB) require custom serialisation and that’s perfectly fine: def orjson_default ( obj: Any ) -> Any: if isinstance(obj, (pendulum.DateTime, pendulum.Date)): return obj.isoformat() if isinstance(obj, Path | Decimal | UUID): return str(obj) if isinstance(obj, set): return list(obj) raise TypeError( f"Type {type(obj)} is not serializable" ) def dumps ( obj: Any ) -> str: return orjson.dumps(obj, default=orjson_default).decode() Exception handling You can (and should) do exactly the same at the repository level: async def _add ( self, profile: Profile, connection: Connection ) -> None : query = """ INSERT INTO profiles ( id, public_id, email, nickname, state, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7) """ try : await self.db.execute(query, [ profile.id, profile.public_id, profile.email, profile.nickname, profile.state, profile.created_at, profile.updated_at, ], connection) except UniqueViolationError as exc: raise ProfileUniqueViolationError from exc Consistent domain model When an ORM hides the persistence layer, you lose visibility into how data is actually stored, which makes debugging, analytics, and performance work harder. With explicit factories, you control the mapping directly. They are the single source of truth for how database records become domain objects. In Hypha’s case, these factories are monadic, meaning they encapsulate failure and error-handling behaviour by design, so we can choose whether to propagate, wrap, or suppress errors explicitly. But it's completely out of scope now, and simplified example might look like this: class ProfileFactories : @classmethod def profile_from_record ( cls, rec: Record ) -> Profile | None : try : return Profile( id=UUID(str(rec[ "id" ])), public_id=HyphaID(rec[ "public_id" ]), email=rec[ "email" ], nickname=rec[ "nickname" ], state=ProfileState(rec[ "state" ]), created_at=pendulum.instance(rec[ "created_at" ]), updated_at=pendulum.instance(rec[ "updated_at" ]), ) except Exception: return None Hypha is a real-world demonstration that this approach actually works. With dozens of repositories and services relying on raw SQL, the codebase remains clean, straightforward, and thoroughly tested. The absence of an ORM didn’t make the system harder. It made it simpler, more predictable, and easier to reason about.
