惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
C
Cyber Attacks, Cyber Crime and Cyber Security
博客园 - 司徒正美
月光博客
月光博客
Hugging Face - Blog
Hugging Face - Blog
T
Tailwind CSS Blog
罗磊的独立博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - 三生石上(FineUI控件)
博客园_首页
博客园 - 【当耐特】
Cisco Talos Blog
Cisco Talos Blog
J
Java Code Geeks
C
CXSECURITY Database RSS Feed - CXSecurity.com
S
SegmentFault 最新的问题
人人都是产品经理
人人都是产品经理
Jina AI
Jina AI
AWS News Blog
AWS News Blog
S
Schneier on Security
NISL@THU
NISL@THU
F
Fortinet All Blogs
L
LINUX DO - 热门话题
Google DeepMind News
Google DeepMind News
量子位
IT之家
IT之家
T
The Exploit Database - CXSecurity.com
爱范儿
爱范儿
GbyAI
GbyAI
T
The Blog of Author Tim Ferriss
T
Tor Project blog
V
Vulnerabilities – Threatpost
V
Visual Studio Blog
宝玉的分享
宝玉的分享
Spread Privacy
Spread Privacy
L
Lohrmann on Cybersecurity
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Y
Y Combinator Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
P
Privacy International News Feed
S
Securelist
P
Palo Alto Networks Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
A
Arctic Wolf
T
Tenable Blog
B
Blog
C
CERT Recently Published Vulnerability Notes
P
Proofpoint News Feed
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
T
Threat Research - Cisco Blogs
T
Threatpost

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Hand-Rolled Mappers vs AutoMapper: Keeping the PHP Domain Pure at the Boundary
Gabriel Anhaia · 2026-06-14 · via DEV Community

You add a column to a table. discount_cents, nullable, default zero. A small migration, a five-minute ticket. The next morning a teammate pings you: orders placed overnight have a discount of null where the domain expected zero, and the total calculation now throws a TypeError deep inside a value object.

You trace it back. A reflection-based mapper copied the new column straight onto a property the domain class did not declare, then the next read pulled it back as null and handed it to Money. Nobody wrote the line that connected discount_cents to the domain. The mapper guessed, and the guess leaked the table's shape into a class that was supposed to know nothing about tables.

This is the case against automatic mapping at the persistence boundary. A hand-rolled mapper is more typing. It is also the seam that stops the database schema from reaching into your domain.

What the boundary is for

In a hexagonal layout the domain Order knows about line items, currencies, and the rule that an order needs at least one item. It does not know there is a orders table with a discount_cents column. The repository adapter is the only place those two worlds touch, and the mapper is the translator that stands at that seam.

A reflection or annotation-driven mapper collapses the seam. It reads the persistence shape, walks the domain object's properties, and copies whatever names line up. When the two shapes match, it feels free. When they drift, the drift travels silently across the boundary you built it to protect: a renamed column, a new field, a type that does not round-trip.

The whole point of the adapter is that the domain and the table can change on different days for different reasons. An automatic mapper ties them back together by name. You get the coupling you were trying to avoid, hidden behind a library that promises convenience.

The reflection mapper

Here is the shape of an automatic mapper. It pulls a row, instantiates the domain class without a constructor, and assigns each column to the matching property via reflection.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Persistence;

final class ReflectionMapper
{
    public function toDomain(array $row, string $class): object
    {
        $ref = new \ReflectionClass($class);
        $object = $ref->newInstanceWithoutConstructor();

        foreach ($row as $column => $value) {
            $prop = $this->camelCase($column);
            if ($ref->hasProperty($prop)) {
                $p = $ref->getProperty($prop);
                $p->setValue($object, $value);
            }
        }

        return $object;
    }

    private function camelCase(string $s): string
    {
        return lcfirst(str_replace(
            ' ',
            '',
            ucwords(str_replace('_', ' ', $s)),
        ));
    }
}

Read what it actually does. It bypasses the constructor, so no invariant runs. It matches on a name convention, so a column rename breaks it at runtime, not at compile time. It assigns raw scalars to typed properties, so discount_cents lands on a property the domain never meant to expose, or worse, a null reaches a non-nullable Money field and explodes three calls deep.

The mapper has no idea that amount_minor plus currency are supposed to become one Money value object. It sees two columns and two properties, or it sees them missing and skips them. Either way the domain's actual rules (the ones that make Order an Order) never get a vote.

The hand-rolled mapper

Now the explicit version. Twenty lines, both directions stated by hand.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Persistence;

use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\OrderStatus;
use App\Domain\Shared\Money;

final class OrderMapper
{
    public function toDomain(array $row): Order
    {
        return Order::fromPersistence(
            id: new OrderId($row['id']),
            customerId: new CustomerId($row['customer_id']),
            items: $this->itemsFromJson($row['items']),
            total: new Money(
                (int) $row['total_minor'],
                $row['currency'],
            ),
            status: OrderStatus::from($row['status']),
            placedAt: new \DateTimeImmutable($row['placed_at']),
        );
    }

    public function toRow(Order $order): array
    {
        return [
            'id' => $order->id()->value,
            'customer_id' => $order->customerId()->value,
            'items' => $this->itemsToJson($order->items()),
            'total_minor' => $order->total()->amountInMinorUnits,
            'currency' => $order->total()->currency,
            'status' => $order->status()->value,
            'placed_at' => $order->placedAt()
                ->format('Y-m-d H:i:s'),
        ];
    }
}

Every line is a decision you can read in code review. total_minor and currency become one Money. status goes through the enum's from, which throws on an unknown value instead of smuggling a bad string into the domain. The constructor route is Order::fromPersistence, a named factory the domain owns, not a reflection backdoor that skips every check.

When you add discount_cents, nothing happens until you add a line for it here. The new column sits in the table, invisible to the domain, until you decide what it means and write the translation. The mapper ignores the column until you choose to map it, so a new field is a deliberate decision instead of a silent guess.

What you give up, and why it is fine

The objection is real: explicit mapping is repetitive. Two methods per entity, a line per field, and you write them by hand every time. A 40-column table is a 40-line mapper in each direction. That feels like a tax.

It is a tax you pay once and read forever. The reflection mapper saves you the forty lines today and charges you on every schema change, every column rename, every type that does not round-trip cleanly, every debugging session that starts with "why is this field null." The hand-rolled version turns all of those into a single, greppable place: OrderMapper. You know exactly where the table meets the domain, because there is one file where it happens.

There is a quieter benefit. The mapper is the document that records what the persistence shape actually is. A new engineer reads OrderMapper::toRow and learns the table layout and the domain layout and the relationship between them in one screen. No annotation soup, no reflection magic to trace through a vendor directory.

Where Doctrine fits

Doctrine sits in the middle of this argument. Its default hydrator uses newInstanceWithoutConstructor and reflection, which is the automatic approach. That is fine for an active-record style where the entity is the table. It is not fine when the entity is a domain aggregate with invariants and value objects.

The clean move is the same one the complete hexagonal service uses: map a plain OrderRecord persistence class with Doctrine annotations, let Doctrine hydrate that record automatically, and run your hand-rolled mapper between the record and the domain Order. Doctrine gets the dumb data holder it is good at. The domain gets a translator that respects its factories and value objects.

public function findById(OrderId $id): ?Order
{
    $record = $this->em->find(
        OrderRecord::class,
        $id->value,
    );

    return $record === null
        ? null
        : $this->mapper->toDomain($record);
}

Two layers of mapping, on purpose. The reflection one stays in vendor code, mapping rows to a record class whose only job is to hold data. The explicit one is yours, mapping records to a domain object whose job is to mean something. The seam between them is the line you control.

The rule of thumb

Use automatic mapping for data that is only ever data — read models, projections, DTOs you serialize and forget. The shape is the shape; there is nothing to protect.

Hand-roll the mapper at any boundary where one side is a domain aggregate. The extra lines are the price of a domain that does not learn about your table layout by accident. A schema change should be a decision you make at the seam, not a surprise that arrives in production at 3 a.m.

The next time a mapper "just works" across a schema change, ask what it did with the columns you did not mean for it to see.


If this was useful

The persistence boundary, and the discipline of keeping the domain ignorant of how it is stored, is one of the threads Decoupled PHP pulls all the way through — from value objects and aggregates to repositories, the outbox, and migrating a framework-coupled codebase one slice at a time. The goal is an application where the table can change without the domain noticing, and the framework stays an adapter rather than the protagonist.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.