▲ 56 r/devsmeli+2 crossposts

MercadoPago me suspendió la cuenta el mismo día que lanzaba mi MVP, sin aviso, y soporte no ayudó en nada

Tengo 18 años y hace meses vengo laburando (yo solo en el backend, con mi socia en el frontend) en una SaaS para negocios de servicios (barberías, consultorios, gimnasios, etc). Integré MercadoPago para cobros y suscripciones, laburé un montón en la arquitectura de pagos, reintentos, conciliación, todo.

Llegó el día de lanzar con clientes reales. Como corresponde, antes de largarlo hice varias pruebas de pago para confirmar que todo funcionaba bien end-to-end. Total normal, ¿no? Bueno, no para MercadoPago: probar varias veces les pareció "comportamiento irregular" y directamente me voltearon la cuenta. Sin ningún aviso previo, sin un mail de "che, ojo que estás activando una alerta", nada. De un momento a otro, bloqueada.

Me llegó esto:

> **Suspendimos tu cuenta** > Notamos comportamientos irregulares en tus transacciones y, por seguridad, decidimos suspender tu cuenta. > > Si tienes saldo, quedará retenido hasta el 31 de agosto para cubrir posibles contracargos, reclamos o deudas. Pasado este plazo, podrás retirarlo a una cuenta bancaria. > > Si crees que es un error, contáctanos y envíanos la siguiente documentación: datos del negocio, referencias web, documento de respaldo (registro fiscal o habilitación comercial).

Me contacté con soporte, estuve un buen rato explicando la situación, mandé lo que me pedían. Dos horas después me llega... el mismo mail genérico de suspensión, calcado al primero. O sea, ni siquiera me respondieron algo puntual, fue como hablar con una pared.

Y no es la primera vez que me cruzo con MercadoPago siendo un dolor de cabeza: la documentación de la API deja mucho que desear, el sandbox no se comporta como el ambiente real, y ahora esto. Bloquear una cuenta de producción, el día del lanzamiento, sin previo aviso, por hacer pruebas de pago (algo que cualquiera que integra un gateway de cobro tiene que hacer antes de salir a producción), me parece un montón.

¿Le pasó a alguien más? ¿Cómo lo resolvieron, si es que lo resolvieron?

reddit.com
u/Ok_Two_2900 — 2 days ago
▲ 7 r/Burises+1 crossposts

¿Alguien paga AWS (u otro servicio internacional online) con débito de Itaú?

Buenas! Tengo un proyecto que corre en AWS y voy a usar mi tarjeta de débito Itaú (U25) para pagar la facturación mensual.

Es la primera vez que AWS me va a cobrar (antes usaba los créditos gratuitos que te dan al crearte la cuenta), así que me da un poco de incertidumbre. Saben si mi tarjeta ya sirve o tengo que activar algo especial? Disculpen mi ignorancia

reddit.com
u/Ok_Two_2900 — 23 days ago

Diseñando el esquema del módulo de pagos de un monolito modular en Laravel — evalué Herencia de Tabla Única vs Herencia de Tabla de Clase vs Herencia de Tabla Concreta

Entiendo perfectamente que no hay un patrón perfecto que se pueda aplicar en cualquier contexto, sino que hay que encontrar y elegir el que más se adapte a las necesidades de lo que estés haciendo.

Contexto

Estoy armando un monolito modular en Laravel (varios módulos separados, cada uno con su propio dominio) y en este momento estoy diseñando el módulo de pagos. Ha sido, sinceramente, el más difícil de todo mi proyecto, por cuatro razones:

  1. Obviamente, todo lo que tenga que ver con pagos siempre es más complejo — no hay margen de error con plata real de por medio.
  2. Es multi-gateway — agnóstico al proveedor. Tiene que funcionar para Mercado Pago (soy de Latam), Stripe, y en el futuro cualquier otro (PayPal, etc.), sin tener que tocar nada del resto del sistema al agregar uno nuevo. Esto lo logré definiendo un contrato único (GatewayContract, un Port en términos de arquitectura hexagonal) con los métodos que cualquier gateway de pagos necesita exponer (crear una sesión de pago, cobrar, consultar el estado de una sesión, obtener moneda/credenciales). Cada gateway real (Mercado Pago hoy, Stripe a futuro) implementa ese mismo contrato con su propia lógica interna — el resto del sistema nunca sabe ni le importa con cuál gateway está hablando en un momento dado, solo conoce el contrato. Qué gateway usar para un tenant/cliente puntual se resuelve en tiempo de ejecución contra una configuración, no está hardcodeado en ningún lado.
  3. Es agnóstico al módulo consumidor — el módulo de pagos expone ese mismo contrato público para que lo use cualquier otro módulo (reservas, productos, suscripciones, lo que sea), sin que el módulo de pagos sepa ni le importe quién lo está llamando ni para qué. Puede sonar obvio dicho así, pero vengo migrando desde un monolito convencional donde tenía, literalmente en el mismo archivo, la creación de una reserva Y la llamada directa al gateway de pago mezcladas — sí, lo sé, no hace falta que me lo digan. Separar esto de verdad, con una interfaz genérica en el medio en vez de una llamada directa acoplada, es la parte que más trabajo mental me costó de todo el rediseño.
  4. Tiene que funcionar para pago con tarjeta, transferencia bancaria, y ticket (un método de pago en efectivo específico de mi país).

sesiones_de_pago

Si alguien usó Stripe, es básicamente el equivalente a un PaymentIntent — agrupa reintentos de cobro bajo una misma sesión (el cliente puede fallar con tarjeta y reintentar con transferencia, sin perder el contexto de que es la misma compra).

CREATE TABLE sesiones_de_pago (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_cliente BIGINT NOT NULL,
    metodo_de_pago VARCHAR CHECK (metodo_de_pago IN ('tarjeta','transferencia','ticket')), -- nullable, mutable mientras estado='pendiente'
    estado VARCHAR NOT NULL DEFAULT 'pendiente' CHECK (estado IN ('pendiente','procesando','aprobada')),
    monto NUMERIC NOT NULL,
    moneda VARCHAR NOT NULL,
    created_at TIMESTAMP DEFAULT now()
);

Nota rápida sobre el candado de concurrencia: para evitar que dos clics del botón de pago disparen dos cobros en paralelo, uso un UPDATE sesiones_de_pago SET estado = 'procesando' WHERE id = ? AND estado = 'pendiente' como operación atómica — Postgres lockea la fila, así que el segundo intento concurrente simplemente no encuentra ninguna fila que matchee el WHERE y no hace nada. Sin condición de carrera.

Con esta tabla estoy tranquilo. El problema real lo tengo con intentos_de_cobro (o cobros, para acortar). ¿Por qué? Porque cada método de pago tiene columnas bastante distintas entre sí: tarjeta necesita marca_tarjeta (visa, mastercard, etc.) y gateway_ref (la referencia externa que devuelve el gateway); transferencia — que ni siquiera pasa por ningún gateway, es 100% manual: el cliente sube un comprobante y alguien lo revisa a mano — necesita comprobante; ticket necesita codigo_de_barras y vencimiento.

Así se vería mi esquema con cada uno de los tres patrones:

Herencia de Tabla Única

CREATE TABLE intentos_de_cobro (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    metodo_de_pago VARCHAR NOT NULL, -- esta es nuestra columna discriminadora
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,

    -- Atributos específicos de cobros con tarjeta
    marca_tarjeta VARCHAR,
    gateway_ref VARCHAR,
    respuesta_cruda JSONB,

    -- Atributos específicos de cobros con transferencia
    comprobante VARCHAR,

    -- Atributos específicos de cobros con ticket
    codigo_de_barras VARCHAR,
    vencimiento TIMESTAMP
);

Es el patrón más sencillo, usa un simple discriminador (metodo_de_pago), y también es el de mejor rendimiento (solo hay que escribir y leer una tabla, sin ningún JOIN). Pero se pierden algunas de las funciones de integridad de datos integradas de la base de datos. Por ejemplo, no podés establecer la columna gateway_ref como NOT NULL solo para los cobros con tarjeta — tenés que aplicar esa regla en el código de tu aplicación, o mediante restricciones CHECK bastante más complejas de mantener.

Esto se puede mitigar con un CHECK bien armado, o guardando el detalle específico en un campo jsonb (que Postgres soporta muy bien, con índices GIN incluso) — pero de cualquiera de las dos formas, se vuelve más incómodo de mantener a medida que agregás campos nuevos con el tiempo.

Herencia de Tabla de Clase

CREATE TABLE cobros (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    metodo_de_pago VARCHAR NOT NULL,
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL
);

CREATE TABLE cobros_tarjeta (
    id BIGINT PRIMARY KEY REFERENCES cobros(id),
    marca_tarjeta VARCHAR,
    gateway_ref VARCHAR,
    respuesta_cruda JSONB
);

CREATE TABLE cobros_transferencia (
    id BIGINT PRIMARY KEY REFERENCES cobros(id),
    comprobante VARCHAR
);

CREATE TABLE cobros_ticket (
    id BIGINT PRIMARY KEY REFERENCES cobros(id),
    codigo_de_barras VARCHAR,
    vencimiento TIMESTAMP
);

Una tabla "padre" con los campos comunes, y una tabla "hija" por cada método, con solo lo específico de cada uno. Se gana integridad real (NOT NULL de verdad en cada columna específica, sin nulls por todos lados) y las tablas quedan más fáciles de leer de forma aislada. El truco de poner id BIGINT PRIMARY KEY REFERENCES cobros(id) en la hija (en vez de un id propio + una columna FOREIGN KEY aparte) evita tener que acordarte de un UNIQUE extra para garantizar que no haya dos filas hijas para el mismo padre — la PK ya lo garantiza sola, por definición.

El problema clásico y documentado de este patrón: nada garantiza que exista exactamente una fila hija por cada fila padre, en la tabla correcta de las mutuamente excluyentes. Podés terminar con un padre sin ninguna hija, o (peor) con una fila en cobros_tarjeta Y otra en cobros_transferencia, ambas con el mismo id, contradiciendo el discriminador — nada en un CHECK normal puede evitar esto, porque un CHECK solo puede validar contra columnas de la misma fila, nunca contra otra tabla. Lo que de verdad resolvería esto de forma 100% declarativa, sin trigger, es una feature del estándar SQL (CREATE ASSERTION, permite constraints arbitrarios entre tablas) que ningún motor mayor implementó nunca en serio — ni siquiera Postgres la tiene hoy (recién hay una propuesta muy reciente en su lista de desarrollo para agregarla, después de que Oracle la sumara este año).

Nota sobre las transacciones: al insertar el padre y la hija hace falta envolver ambos INSERT en una misma transacción, para que sea atómico (si uno falla, el otro se revierte). Pero ojo — la transacción es necesaria, no suficiente: protege contra fallos a mitad de camino, no contra que el código de la aplicación simplemente nunca llegue a ejecutar el INSERT de la hija. Esa parte sigue siendo 100% responsabilidad de la capa de aplicación.

Herencia de Tabla Concreta

CREATE TABLE cobros_tarjeta (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,
    marca_tarjeta VARCHAR,
    gateway_ref VARCHAR,
    respuesta_cruda JSONB
);

CREATE TABLE cobros_transferencia (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,
    comprobante VARCHAR
);

CREATE TABLE cobros_ticket (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,
    codigo_de_barras VARCHAR,
    vencimiento TIMESTAMP
);

En vez de compartir los campos comunes vía una tabla padre, cada tabla "repite" esos campos por su cuenta — sin ninguna relación entre ellas. A primera vista podría parecer ineficiente, ya que se duplican los atributos comunes en todas las tablas. Sin embargo, presenta ventajas importantes en situaciones específicas: las consultas sobre un único tipo de dato son increíblemente rápidas, puesto que no se requieren uniones y cada tabla contiene exactamente lo que se necesita. Las tablas son completamente independientes, por lo que se puede optimizar cada una de forma diferente según sus patrones de acceso específicos, y se pueden añadir, eliminar o modificar atributos de un tipo sin riesgo de afectar a los demás.

La desventaja: si trabajás seguido con todos los métodos a la vez (por ejemplo, un cron que revisa "todos los cobros pendientes, sin importar el método"), necesitás un UNION entre las tres tablas cada vez, lo cual baja el rendimiento comparado con leer una sola tabla. Y a nivel API queda un poco incómodo: si querés devolver "el id de este cobro" hacia afuera, también tenés que devolver el tipo (el discriminador) junto con el id, porque el id solo no te dice en cuál de las tres tablas buscar. Por esa razón no me terminó convenciendo para mi caso puntual, pero me parece un patrón bastante sólido para otros escenarios (leí que Stripe, por ejemplo, parece usar algo parecido a esto para partes de su propio esquema).

Lo que se me ocurrió (y resulta que ya existe, con nombre y todo)

Estaba dudando entre Herencia de Tabla Única con el detalle específico en un jsonb, o Herencia de Tabla de Clase aceptando el trade-off de integridad de siempre. Pero se me ocurrió algo que creo que resuelve justo ese problema de integridad:

En vez de que la FK de cada tabla hija apunte solo al id del padre, la hago apuntar a (id, metodo_de_pago) juntos — una FK compuesta contra un UNIQUE(id, metodo_de_pago) en el padre. Cada tabla hija fuerza su propio metodo_de_pago a un valor fijo con un CHECK:

CREATE TABLE cobros (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    metodo_de_pago VARCHAR NOT NULL CHECK (metodo_de_pago IN ('tarjeta','transferencia','ticket')),
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,
    UNIQUE (id, metodo_de_pago)
);

CREATE TABLE cobros_tarjeta (
    id BIGINT PRIMARY KEY,
    metodo_de_pago VARCHAR NOT NULL DEFAULT 'tarjeta' CHECK (metodo_de_pago = 'tarjeta'),
    marca_tarjeta VARCHAR,
    gateway_ref VARCHAR,
    FOREIGN KEY (id, metodo_de_pago) REFERENCES cobros (id, metodo_de_pago)
);

CREATE TABLE cobros_transferencia (
    id BIGINT PRIMARY KEY,
    metodo_de_pago VARCHAR NOT NULL DEFAULT 'transferencia' CHECK (metodo_de_pago = 'transferencia'),
    comprobante VARCHAR,
    FOREIGN KEY (id, metodo_de_pago) REFERENCES cobros (id, metodo_de_pago)
);

Con esto, para que una fila se cuele en la tabla hija equivocada, la fila padre tendría que decir también ese metodo_de_pago — algo imposible, porque el metodo_de_pago del padre se fija una sola vez al crear el cobro y nunca cambia después.

No es una idea original mía — ya tiene nombre en la literatura

Después de darle vueltas, encontré que esto ya es un patrón conocido (aunque poco difundido fuera de círculos de modelado relacional): se lo suele llamar "distributed keys" o "disjoint subtypes" vía claves compuestas, y resuelve la parte de disyunción de una restricción de especialización/generalización EER (que un registro no pueda pertenecer a dos subtipos a la vez). Ojo que no resuelve la parte de totalidad (que todo padre esté obligado a tener alguna hija) — esa sigue dependiendo de la transacción + la aplicación, como mencioné arriba.

Ahora, las preguntas que me quedan dando vueltas:

  • ¿Alguien lo usó en producción? Y si es así, ¿qué problemas le encontraron en el camino que yo todavía no vi?
  • ¿O directamente es mejor irse por lo simple y aceptar el trade-off — Herencia de Tabla Única con el detalle específico en un jsonb — en vez de toda esta vuelta con claves compuestas?
reddit.com
u/Ok_Two_2900 — 24 days ago

Designing the payments module schema for a modular monolith in Laravel — I evaluated Single Table Inheritance vs Class Table Inheritance vs Concrete Table Inheritance

I fully understand there's no perfect pattern that applies to every context — you have to find and pick whichever fits best for what you're building.

Context

I'm building a modular monolith in Laravel (several separate modules, each with its own domain), and right now I'm designing the payments module. Honestly, it's been the hardest part of the whole project, for four reasons:

  1. Obviously, anything involving payments is inherently more complex — there's no room for error when real money is on the line.
  2. It's multi-gateway — provider-agnostic. It has to work with Mercado Pago (I'm from Latam), Stripe, and any other provider down the line (PayPal, etc.), without touching the rest of the system when adding a new one. I achieved this by defining a single contract (GatewayContract, a Port in hexagonal architecture terms) with the methods any payment gateway needs to expose (create a payment session, charge, check a session's status, get currency/credentials). Each real gateway (Mercado Pago today, Stripe in the future) implements that same contract with its own internal logic — the rest of the system never knows or cares which gateway it's talking to at any given moment, it only knows the contract. Which gateway to use for a given tenant/customer is resolved at runtime against a configuration, not hardcoded anywhere.
  3. It's agnostic to the consuming module — the payments module exposes that same public contract for any other module to use (bookings, products, subscriptions, whatever), without the payments module knowing or caring who's calling it or why. It might sound obvious put that way, but I'm migrating from a conventional monolith where I had, literally in the same file, booking creation AND a direct call to the payment gateway mixed together — yeah, I know, no need to point it out. Truly separating this, with a generic interface in between instead of a tightly coupled direct call, was the part that cost me the most mental effort in the whole redesign.
  4. It has to support card payments, bank transfer, and ticket (a cash-based payment method specific to my country).

payment_sessions

If you've used Stripe, this is basically the equivalent of a PaymentIntent — it groups charge retries under a single session (the customer can fail with a card and retry with a bank transfer, without losing the context that it's the same purchase).

CREATE TABLE payment_sessions (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    payment_method VARCHAR CHECK (payment_method IN ('card','transfer','ticket')), -- nullable, mutable while status='pending'
    status VARCHAR NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','processing','approved')),
    amount NUMERIC NOT NULL,
    currency VARCHAR NOT NULL,
    created_at TIMESTAMP DEFAULT now()
);

Quick note on the concurrency lock: to prevent two clicks of the pay button from firing two charges in parallel, I use UPDATE payment_sessions SET status = 'processing' WHERE id = ? AND status = 'pending' as an atomic operation — Postgres locks the row, so the second concurrent attempt simply finds no row matching the WHERE clause and does nothing. No race condition.

I'm at peace with this table. The real problem is with charge_attempts (or charges, for short). Why? Because each payment method has fairly different columns: card needs card_brand (visa, mastercard, etc.) and gateway_ref (the external reference the gateway returns); transfer — which doesn't even go through any gateway, it's 100% manual: the customer uploads a receipt and someone reviews it by hand — needs receipt; ticket needs barcode and expiration.

Here's how my schema would look with each of the three patterns:

Single Table Inheritance

CREATE TABLE charge_attempts (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    payment_method VARCHAR NOT NULL, -- this is our discriminator column
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,

    -- Card-specific attributes
    card_brand VARCHAR,
    gateway_ref VARCHAR,
    raw_response JSONB,

    -- Transfer-specific attributes
    receipt VARCHAR,

    -- Ticket-specific attributes
    barcode VARCHAR,
    expiration TIMESTAMP
);

It's the simplest pattern, using a single discriminator (payment_method), and it's also the best-performing one (you only have to write and read one table, no JOIN at all). But you lose some of the database's built-in data integrity features. For example, you can't set the gateway_ref column as NOT NULL only for card charges — you have to enforce that rule in your application code, or via CHECK constraints that get considerably harder to maintain.

This can be mitigated with a well-built CHECK, or by storing the method-specific detail in a jsonb field (which Postgres supports very well, even with GIN indexes) — but either way, it gets more awkward to maintain as you add new fields over time.

Class Table Inheritance

CREATE TABLE charges (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    payment_method VARCHAR NOT NULL,
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL
);

CREATE TABLE charges_card (
    id BIGINT PRIMARY KEY REFERENCES charges(id),
    card_brand VARCHAR,
    gateway_ref VARCHAR,
    raw_response JSONB
);

CREATE TABLE charges_transfer (
    id BIGINT PRIMARY KEY REFERENCES charges(id),
    receipt VARCHAR
);

CREATE TABLE charges_ticket (
    id BIGINT PRIMARY KEY REFERENCES charges(id),
    barcode VARCHAR,
    expiration TIMESTAMP
);

A "parent" table with the common fields, and a "child" table per method, with only what's specific to each one. You gain real integrity (genuine NOT NULL on each method-specific column, no nulls scattered everywhere) and the tables become easier to read in isolation. The trick of putting id BIGINT PRIMARY KEY REFERENCES charges(id) on the child (instead of its own id plus a separate FOREIGN KEY column) avoids having to remember an extra UNIQUE to guarantee there's no more than one child row per parent — the PK already guarantees that on its own, by definition.

The classic, well-documented problem with this pattern: nothing guarantees that exactly one child row exists per parent row, in the correct table among the mutually exclusive ones. You can end up with a parent that has no children at all, or (worse) a row in charges_card AND another in charges_transfer, both with the same id, contradicting the discriminator — no ordinary CHECK can prevent this, because a CHECK can only validate against columns in the same row, never against another table. What would truly solve this in a 100% declarative way, without a trigger, is a SQL standard feature (CREATE ASSERTION, which allows arbitrary cross-table constraints) that no major engine ever seriously implemented — not even Postgres has it today (there's only a very recent proposal on its development list to add it, after Oracle added it this year).

Note on transactions: when inserting the parent and the child, both INSERT statements need to be wrapped in the same transaction, so it's atomic (if one fails, the other rolls back). But careful — the transaction is necessary, not sufficient: it protects against failures midway through, not against the application code simply never getting around to executing the child's INSERT. That part is still 100% the application layer's responsibility.

Concrete Table Inheritance

CREATE TABLE charges_card (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,
    card_brand VARCHAR,
    gateway_ref VARCHAR,
    raw_response JSONB
);

CREATE TABLE charges_transfer (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,
    receipt VARCHAR
);

CREATE TABLE charges_ticket (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,
    barcode VARCHAR,
    expiration TIMESTAMP
);

Instead of sharing common fields through a parent table, each table "repeats" those fields on its own — with no relationship between them at all. At first glance this might look inefficient, since the common attributes get duplicated across every table. However, it has real advantages in specific situations: queries against a single data type are incredibly fast, since no joins are required and each table contains exactly what's needed. The tables are fully independent, so each one can be optimized differently based on its specific access patterns, and attributes for one type can be added, removed, or modified without any risk of affecting the others.

The downside: if you work with all the methods together often (say, a cron job checking "all pending charges, regardless of method"), you need a UNION across the three tables every time, which hurts performance compared to reading a single table. And at the API level it gets a bit awkward too: if you want to return "this charge's id" externally, you also have to return the type (the discriminator) along with the id, because the id alone doesn't tell you which of the three tables to look in. For that reason it didn't end up winning me over for my specific case, but it seems like a pretty solid pattern for other scenarios (I read that Stripe, for example, seems to use something similar for parts of its own schema).

What I came up with (and turns out it already exists, name and all)

I was torn between Single Table Inheritance with the method-specific detail in a jsonb, or Class Table Inheritance accepting the usual integrity trade-off. But I came up with something I think solves exactly that integrity problem:

Instead of each child table's FK pointing only to the parent's id, make it point to (id, payment_method) together — a composite FK against a UNIQUE(id, payment_method) on the parent. Each child table forces its own payment_method to a fixed value with a CHECK:

CREATE TABLE charges (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_method VARCHAR NOT NULL CHECK (payment_method IN ('card','transfer','ticket')),
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,
    UNIQUE (id, payment_method)
);

CREATE TABLE charges_card (
    id BIGINT PRIMARY KEY,
    payment_method VARCHAR NOT NULL DEFAULT 'card' CHECK (payment_method = 'card'),
    card_brand VARCHAR,
    gateway_ref VARCHAR,
    FOREIGN KEY (id, payment_method) REFERENCES charges (id, payment_method)
);

CREATE TABLE charges_transfer (
    id BIGINT PRIMARY KEY,
    payment_method VARCHAR NOT NULL DEFAULT 'transfer' CHECK (payment_method = 'transfer'),
    receipt VARCHAR,
    FOREIGN KEY (id, payment_method) REFERENCES charges (id, payment_method)
);

With this, for a row to sneak into the wrong child table, the parent row would also have to have that same payment_method — which is impossible, because the parent's payment_method is set once when the charge is created and never changes afterward.

It's not an original idea of mine — it already has a name in the literature

After thinking it over, I found that this is actually a known pattern (though not widely covered outside relational-modeling circles): it's usually called "distributed keys" or "disjoint subtypes" via composite keys, and it solves the disjointness part of an EER specialization/generalization constraint (that a record can't belong to two subtypes at once). Note that it doesn't solve the totality part (that every parent must be required to have some child) — that still depends on the transaction + the application, as I mentioned above.

Now, the questions I keep turning over:

  • Has anyone used this in production? And if so, what problems did you run into along the way that I haven't spotted yet?
  • Or is it actually better to just go simple and accept the trade-off — Single Table Inheritance with the method-specific detail in a jsonb — instead of all this composite-key back-and-forth?
reddit.com
u/Ok_Two_2900 — 24 days ago

Designing the payments module schema for a modular monolith in Laravel — I evaluated Single Table Inheritance vs Class Table Inheritance vs Concrete Table Inheritance

I fully understand there's no perfect pattern that applies to every context — you have to find and pick whichever fits best for what you're building.

Context

I'm building a modular monolith in Laravel (several separate modules, each with its own domain), and right now I'm designing the payments module. Honestly, it's been the hardest part of the whole project, for four reasons:

  1. Obviously, anything involving payments is inherently more complex — there's no room for error when real money is on the line.
  2. It's multi-gateway — provider-agnostic. It has to work with Mercado Pago (I'm from Latam), Stripe, and any other provider down the line (PayPal, etc.), without touching the rest of the system when adding a new one. I achieved this by defining a single contract (GatewayContract, a Port in hexagonal architecture terms) with the methods any payment gateway needs to expose (create a payment session, charge, check a session's status, get currency/credentials). Each real gateway (Mercado Pago today, Stripe in the future) implements that same contract with its own internal logic — the rest of the system never knows or cares which gateway it's talking to at any given moment, it only knows the contract. Which gateway to use for a given tenant/customer is resolved at runtime against a configuration, not hardcoded anywhere.
  3. It's agnostic to the consuming module — the payments module exposes that same public contract for any other module to use (bookings, products, subscriptions, whatever), without the payments module knowing or caring who's calling it or why. It might sound obvious put that way, but I'm migrating from a conventional monolith where I had, literally in the same file, booking creation AND a direct call to the payment gateway mixed together — yeah, I know, no need to point it out. Truly separating this, with a generic interface in between instead of a tightly coupled direct call, was the part that cost me the most mental effort in the whole redesign.
  4. It has to support card payments, bank transfer, and ticket (a cash-based payment method specific to my country).

payment_sessions

If you've used Stripe, this is basically the equivalent of a PaymentIntent — it groups charge retries under a single session (the customer can fail with a card and retry with a bank transfer, without losing the context that it's the same purchase).

CREATE TABLE payment_sessions (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    payment_method VARCHAR CHECK (payment_method IN ('card','transfer','ticket')), -- nullable, mutable while status='pending'
    status VARCHAR NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','processing','approved')),
    amount NUMERIC NOT NULL,
    currency VARCHAR NOT NULL,
    created_at TIMESTAMP DEFAULT now()
);

Quick note on the concurrency lock: to prevent two clicks of the pay button from firing two charges in parallel, I use UPDATE payment_sessions SET status = 'processing' WHERE id = ? AND status = 'pending' as an atomic operation — Postgres locks the row, so the second concurrent attempt simply finds no row matching the WHERE clause and does nothing. No race condition.

I'm at peace with this table. The real problem is with charge_attempts (or charges, for short). Why? Because each payment method has fairly different columns: card needs card_brand (visa, mastercard, etc.) and gateway_ref (the external reference the gateway returns); transfer — which doesn't even go through any gateway, it's 100% manual: the customer uploads a receipt and someone reviews it by hand — needs receipt; ticket needs barcode and expiration.

Here's how my schema would look with each of the three patterns:

Single Table Inheritance

CREATE TABLE charge_attempts (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    payment_method VARCHAR NOT NULL, -- this is our discriminator column
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,

    -- Card-specific attributes
    card_brand VARCHAR,
    gateway_ref VARCHAR,
    raw_response JSONB,

    -- Transfer-specific attributes
    receipt VARCHAR,

    -- Ticket-specific attributes
    barcode VARCHAR,
    expiration TIMESTAMP
);

It's the simplest pattern, using a single discriminator (payment_method), and it's also the best-performing one (you only have to write and read one table, no JOIN at all). But you lose some of the database's built-in data integrity features. For example, you can't set the gateway_ref column as NOT NULL only for card charges — you have to enforce that rule in your application code, or via CHECK constraints that get considerably harder to maintain.

This can be mitigated with a well-built CHECK, or by storing the method-specific detail in a jsonb field (which Postgres supports very well, even with GIN indexes) — but either way, it gets more awkward to maintain as you add new fields over time.

Class Table Inheritance

CREATE TABLE charges (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    payment_method VARCHAR NOT NULL,
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL
);

CREATE TABLE charges_card (
    id BIGINT PRIMARY KEY REFERENCES charges(id),
    card_brand VARCHAR,
    gateway_ref VARCHAR,
    raw_response JSONB
);

CREATE TABLE charges_transfer (
    id BIGINT PRIMARY KEY REFERENCES charges(id),
    receipt VARCHAR
);

CREATE TABLE charges_ticket (
    id BIGINT PRIMARY KEY REFERENCES charges(id),
    barcode VARCHAR,
    expiration TIMESTAMP
);

A "parent" table with the common fields, and a "child" table per method, with only what's specific to each one. You gain real integrity (genuine NOT NULL on each method-specific column, no nulls scattered everywhere) and the tables become easier to read in isolation. The trick of putting id BIGINT PRIMARY KEY REFERENCES charges(id) on the child (instead of its own id plus a separate FOREIGN KEY column) avoids having to remember an extra UNIQUE to guarantee there's no more than one child row per parent — the PK already guarantees that on its own, by definition.

The classic, well-documented problem with this pattern: nothing guarantees that exactly one child row exists per parent row, in the correct table among the mutually exclusive ones. You can end up with a parent that has no children at all, or (worse) a row in charges_card AND another in charges_transfer, both with the same id, contradicting the discriminator — no ordinary CHECK can prevent this, because a CHECK can only validate against columns in the same row, never against another table. What would truly solve this in a 100% declarative way, without a trigger, is a SQL standard feature (CREATE ASSERTION, which allows arbitrary cross-table constraints) that no major engine ever seriously implemented — not even Postgres has it today (there's only a very recent proposal on its development list to add it, after Oracle added it this year).

Note on transactions: when inserting the parent and the child, both INSERT statements need to be wrapped in the same transaction, so it's atomic (if one fails, the other rolls back). But careful — the transaction is necessary, not sufficient: it protects against failures midway through, not against the application code simply never getting around to executing the child's INSERT. That part is still 100% the application layer's responsibility.

Concrete Table Inheritance

CREATE TABLE charges_card (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,
    card_brand VARCHAR,
    gateway_ref VARCHAR,
    raw_response JSONB
);

CREATE TABLE charges_transfer (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,
    receipt VARCHAR
);

CREATE TABLE charges_ticket (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_session_id BIGINT NOT NULL REFERENCES payment_sessions(id),
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,
    barcode VARCHAR,
    expiration TIMESTAMP
);

Instead of sharing common fields through a parent table, each table "repeats" those fields on its own — with no relationship between them at all. At first glance this might look inefficient, since the common attributes get duplicated across every table. However, it has real advantages in specific situations: queries against a single data type are incredibly fast, since no joins are required and each table contains exactly what's needed. The tables are fully independent, so each one can be optimized differently based on its specific access patterns, and attributes for one type can be added, removed, or modified without any risk of affecting the others.

The downside: if you work with all the methods together often (say, a cron job checking "all pending charges, regardless of method"), you need a UNION across the three tables every time, which hurts performance compared to reading a single table. And at the API level it gets a bit awkward too: if you want to return "this charge's id" externally, you also have to return the type (the discriminator) along with the id, because the id alone doesn't tell you which of the three tables to look in. For that reason it didn't end up winning me over for my specific case, but it seems like a pretty solid pattern for other scenarios (I read that Stripe, for example, seems to use something similar for parts of its own schema).

What I came up with (and turns out it already exists, name and all)

I was torn between Single Table Inheritance with the method-specific detail in a jsonb, or Class Table Inheritance accepting the usual integrity trade-off. But I came up with something I think solves exactly that integrity problem:

Instead of each child table's FK pointing only to the parent's id, make it point to (id, payment_method) together — a composite FK against a UNIQUE(id, payment_method) on the parent. Each child table forces its own payment_method to a fixed value with a CHECK:

CREATE TABLE charges (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_method VARCHAR NOT NULL CHECK (payment_method IN ('card','transfer','ticket')),
    status VARCHAR NOT NULL,
    amount NUMERIC NOT NULL,
    UNIQUE (id, payment_method)
);

CREATE TABLE charges_card (
    id BIGINT PRIMARY KEY,
    payment_method VARCHAR NOT NULL DEFAULT 'card' CHECK (payment_method = 'card'),
    card_brand VARCHAR,
    gateway_ref VARCHAR,
    FOREIGN KEY (id, payment_method) REFERENCES charges (id, payment_method)
);

CREATE TABLE charges_transfer (
    id BIGINT PRIMARY KEY,
    payment_method VARCHAR NOT NULL DEFAULT 'transfer' CHECK (payment_method = 'transfer'),
    receipt VARCHAR,
    FOREIGN KEY (id, payment_method) REFERENCES charges (id, payment_method)
);

With this, for a row to sneak into the wrong child table, the parent row would also have to have that same payment_method — which is impossible, because the parent's payment_method is set once when the charge is created and never changes afterward.

It's not an original idea of mine — it already has a name in the literature

After thinking it over, I found that this is actually a known pattern (though not widely covered outside relational-modeling circles): it's usually called "distributed keys" or "disjoint subtypes" via composite keys, and it solves the disjointness part of an EER specialization/generalization constraint (that a record can't belong to two subtypes at once). Note that it doesn't solve the totality part (that every parent must be required to have some child) — that still depends on the transaction + the application, as I mentioned above.

Now, the questions I keep turning over:

  • Has anyone used this in production? And if so, what problems did you run into along the way that I haven't spotted yet?
  • Or is it actually better to just go simple and accept the trade-off — Single Table Inheritance with the method-specific detail in a jsonb — instead of all this composite-key back-and-forth?
reddit.com
u/Ok_Two_2900 — 24 days ago

Diseñando el esquema del módulo de pagos de un monolito modular en Laravel — evalué Herencia de Tabla Única vs Herencia de Tabla de Clase vs Herencia de Tabla Concreta

Entiendo perfectamente que no hay un patrón perfecto que se pueda aplicar en cualquier contexto, sino que hay que encontrar y elegir el que más se adapte a las necesidades de lo que estés haciendo.

Contexto

Estoy armando un monolito modular en Laravel (varios módulos separados, cada uno con su propio dominio) y en este momento estoy diseñando el módulo de pagos. Ha sido, sinceramente, el más difícil de todo mi proyecto, por cuatro razones:

  1. Obviamente, todo lo que tenga que ver con pagos siempre es más complejo — no hay margen de error con plata real de por medio.
  2. Es multi-gateway — agnóstico al proveedor. Tiene que funcionar para Mercado Pago (soy de Latam), Stripe, y en el futuro cualquier otro (PayPal, etc.), sin tener que tocar nada del resto del sistema al agregar uno nuevo. Esto lo logré definiendo un contrato único (GatewayContract, un Port en términos de arquitectura hexagonal) con los métodos que cualquier gateway de pagos necesita exponer (crear una sesión de pago, cobrar, consultar el estado de una sesión, obtener moneda/credenciales). Cada gateway real (Mercado Pago hoy, Stripe a futuro) implementa ese mismo contrato con su propia lógica interna — el resto del sistema nunca sabe ni le importa con cuál gateway está hablando en un momento dado, solo conoce el contrato. Qué gateway usar para un tenant/cliente puntual se resuelve en tiempo de ejecución contra una configuración, no está hardcodeado en ningún lado.
  3. Es agnóstico al módulo consumidor — el módulo de pagos expone ese mismo contrato público para que lo use cualquier otro módulo (reservas, productos, suscripciones, lo que sea), sin que el módulo de pagos sepa ni le importe quién lo está llamando ni para qué. Puede sonar obvio dicho así, pero vengo migrando desde un monolito convencional donde tenía, literalmente en el mismo archivo, la creación de una reserva Y la llamada directa al gateway de pago mezcladas — sí, lo sé, no hace falta que me lo digan. Separar esto de verdad, con una interfaz genérica en el medio en vez de una llamada directa acoplada, es la parte que más trabajo mental me costó de todo el rediseño.
  4. Tiene que funcionar para pago con tarjeta, transferencia bancaria, y ticket (un método de pago en efectivo específico de mi país).

sesiones_de_pago

Si alguien usó Stripe, es básicamente el equivalente a un PaymentIntent — agrupa reintentos de cobro bajo una misma sesión (el cliente puede fallar con tarjeta y reintentar con transferencia, sin perder el contexto de que es la misma compra).

CREATE TABLE sesiones_de_pago (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_cliente BIGINT NOT NULL,
    metodo_de_pago VARCHAR CHECK (metodo_de_pago IN ('tarjeta','transferencia','ticket')), -- nullable, mutable mientras estado='pendiente'
    estado VARCHAR NOT NULL DEFAULT 'pendiente' CHECK (estado IN ('pendiente','procesando','aprobada')),
    monto NUMERIC NOT NULL,
    moneda VARCHAR NOT NULL,
    created_at TIMESTAMP DEFAULT now()
);

Nota rápida sobre el candado de concurrencia: para evitar que dos clics del botón de pago disparen dos cobros en paralelo, uso un UPDATE sesiones_de_pago SET estado = 'procesando' WHERE id = ? AND estado = 'pendiente' como operación atómica — Postgres lockea la fila, así que el segundo intento concurrente simplemente no encuentra ninguna fila que matchee el WHERE y no hace nada. Sin condición de carrera.

Con esta tabla estoy tranquilo. El problema real lo tengo con intentos_de_cobro (o cobros, para acortar). ¿Por qué? Porque cada método de pago tiene columnas bastante distintas entre sí: tarjeta necesita marca_tarjeta (visa, mastercard, etc.) y gateway_ref (la referencia externa que devuelve el gateway); transferencia — que ni siquiera pasa por ningún gateway, es 100% manual: el cliente sube un comprobante y alguien lo revisa a mano — necesita comprobante; ticket necesita codigo_de_barras y vencimiento.

Así se vería mi esquema con cada uno de los tres patrones:

Herencia de Tabla Única

CREATE TABLE intentos_de_cobro (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    metodo_de_pago VARCHAR NOT NULL, -- esta es nuestra columna discriminadora
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,

    -- Atributos específicos de cobros con tarjeta
    marca_tarjeta VARCHAR,
    gateway_ref VARCHAR,
    respuesta_cruda JSONB,

    -- Atributos específicos de cobros con transferencia
    comprobante VARCHAR,

    -- Atributos específicos de cobros con ticket
    codigo_de_barras VARCHAR,
    vencimiento TIMESTAMP
);

Es el patrón más sencillo, usa un simple discriminador (metodo_de_pago), y también es el de mejor rendimiento (solo hay que escribir y leer una tabla, sin ningún JOIN). Pero se pierden algunas de las funciones de integridad de datos integradas de la base de datos. Por ejemplo, no podés establecer la columna gateway_ref como NOT NULL solo para los cobros con tarjeta — tenés que aplicar esa regla en el código de tu aplicación, o mediante restricciones CHECK bastante más complejas de mantener.

Esto se puede mitigar con un CHECK bien armado, o guardando el detalle específico en un campo jsonb (que Postgres soporta muy bien, con índices GIN incluso) — pero de cualquiera de las dos formas, se vuelve más incómodo de mantener a medida que agregás campos nuevos con el tiempo.

Herencia de Tabla de Clase

CREATE TABLE cobros (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    metodo_de_pago VARCHAR NOT NULL,
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL
);

CREATE TABLE cobros_tarjeta (
    id BIGINT PRIMARY KEY REFERENCES cobros(id),
    marca_tarjeta VARCHAR,
    gateway_ref VARCHAR,
    respuesta_cruda JSONB
);

CREATE TABLE cobros_transferencia (
    id BIGINT PRIMARY KEY REFERENCES cobros(id),
    comprobante VARCHAR
);

CREATE TABLE cobros_ticket (
    id BIGINT PRIMARY KEY REFERENCES cobros(id),
    codigo_de_barras VARCHAR,
    vencimiento TIMESTAMP
);

Una tabla "padre" con los campos comunes, y una tabla "hija" por cada método, con solo lo específico de cada uno. Se gana integridad real (NOT NULL de verdad en cada columna específica, sin nulls por todos lados) y las tablas quedan más fáciles de leer de forma aislada. El truco de poner id BIGINT PRIMARY KEY REFERENCES cobros(id) en la hija (en vez de un id propio + una columna FOREIGN KEY aparte) evita tener que acordarte de un UNIQUE extra para garantizar que no haya dos filas hijas para el mismo padre — la PK ya lo garantiza sola, por definición.

El problema clásico y documentado de este patrón: nada garantiza que exista exactamente una fila hija por cada fila padre, en la tabla correcta de las mutuamente excluyentes. Podés terminar con un padre sin ninguna hija, o (peor) con una fila en cobros_tarjeta Y otra en cobros_transferencia, ambas con el mismo id, contradiciendo el discriminador — nada en un CHECK normal puede evitar esto, porque un CHECK solo puede validar contra columnas de la misma fila, nunca contra otra tabla. Lo que de verdad resolvería esto de forma 100% declarativa, sin trigger, es una feature del estándar SQL (CREATE ASSERTION, permite constraints arbitrarios entre tablas) que ningún motor mayor implementó nunca en serio — ni siquiera Postgres la tiene hoy (recién hay una propuesta muy reciente en su lista de desarrollo para agregarla, después de que Oracle la sumara este año).

Nota sobre las transacciones: al insertar el padre y la hija hace falta envolver ambos INSERT en una misma transacción, para que sea atómico (si uno falla, el otro se revierte). Pero ojo — la transacción es necesaria, no suficiente: protege contra fallos a mitad de camino, no contra que el código de la aplicación simplemente nunca llegue a ejecutar el INSERT de la hija. Esa parte sigue siendo 100% responsabilidad de la capa de aplicación.

Herencia de Tabla Concreta

CREATE TABLE cobros_tarjeta (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,
    marca_tarjeta VARCHAR,
    gateway_ref VARCHAR,
    respuesta_cruda JSONB
);

CREATE TABLE cobros_transferencia (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,
    comprobante VARCHAR
);

CREATE TABLE cobros_ticket (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_sesion_de_pago BIGINT NOT NULL REFERENCES sesiones_de_pago(id),
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,
    codigo_de_barras VARCHAR,
    vencimiento TIMESTAMP
);

En vez de compartir los campos comunes vía una tabla padre, cada tabla "repite" esos campos por su cuenta — sin ninguna relación entre ellas. A primera vista podría parecer ineficiente, ya que se duplican los atributos comunes en todas las tablas. Sin embargo, presenta ventajas importantes en situaciones específicas: las consultas sobre un único tipo de dato son increíblemente rápidas, puesto que no se requieren uniones y cada tabla contiene exactamente lo que se necesita. Las tablas son completamente independientes, por lo que se puede optimizar cada una de forma diferente según sus patrones de acceso específicos, y se pueden añadir, eliminar o modificar atributos de un tipo sin riesgo de afectar a los demás.

La desventaja: si trabajás seguido con todos los métodos a la vez (por ejemplo, un cron que revisa "todos los cobros pendientes, sin importar el método"), necesitás un UNION entre las tres tablas cada vez, lo cual baja el rendimiento comparado con leer una sola tabla. Y a nivel API queda un poco incómodo: si querés devolver "el id de este cobro" hacia afuera, también tenés que devolver el tipo (el discriminador) junto con el id, porque el id solo no te dice en cuál de las tres tablas buscar. Por esa razón no me terminó convenciendo para mi caso puntual, pero me parece un patrón bastante sólido para otros escenarios (leí que Stripe, por ejemplo, parece usar algo parecido a esto para partes de su propio esquema).

Lo que se me ocurrió (y resulta que ya existe, con nombre y todo)

Estaba dudando entre Herencia de Tabla Única con el detalle específico en un jsonb, o Herencia de Tabla de Clase aceptando el trade-off de integridad de siempre. Pero se me ocurrió algo que creo que resuelve justo ese problema de integridad:

En vez de que la FK de cada tabla hija apunte solo al id del padre, la hago apuntar a (id, metodo_de_pago) juntos — una FK compuesta contra un UNIQUE(id, metodo_de_pago) en el padre. Cada tabla hija fuerza su propio metodo_de_pago a un valor fijo con un CHECK:

CREATE TABLE cobros (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    metodo_de_pago VARCHAR NOT NULL CHECK (metodo_de_pago IN ('tarjeta','transferencia','ticket')),
    estado VARCHAR NOT NULL,
    monto NUMERIC NOT NULL,
    UNIQUE (id, metodo_de_pago)
);

CREATE TABLE cobros_tarjeta (
    id BIGINT PRIMARY KEY,
    metodo_de_pago VARCHAR NOT NULL DEFAULT 'tarjeta' CHECK (metodo_de_pago = 'tarjeta'),
    marca_tarjeta VARCHAR,
    gateway_ref VARCHAR,
    FOREIGN KEY (id, metodo_de_pago) REFERENCES cobros (id, metodo_de_pago)
);

CREATE TABLE cobros_transferencia (
    id BIGINT PRIMARY KEY,
    metodo_de_pago VARCHAR NOT NULL DEFAULT 'transferencia' CHECK (metodo_de_pago = 'transferencia'),
    comprobante VARCHAR,
    FOREIGN KEY (id, metodo_de_pago) REFERENCES cobros (id, metodo_de_pago)
);

Con esto, para que una fila se cuele en la tabla hija equivocada, la fila padre tendría que decir también ese metodo_de_pago — algo imposible, porque el metodo_de_pago del padre se fija una sola vez al crear el cobro y nunca cambia después.

No es una idea original mía — ya tiene nombre en la literatura

Después de darle vueltas, encontré que esto ya es un patrón conocido (aunque poco difundido fuera de círculos de modelado relacional): se lo suele llamar "distributed keys" o "disjoint subtypes" vía claves compuestas, y resuelve la parte de disyunción de una restricción de especialización/generalización EER (que un registro no pueda pertenecer a dos subtipos a la vez). Ojo que no resuelve la parte de totalidad (que todo padre esté obligado a tener alguna hija) — esa sigue dependiendo de la transacción + la aplicación, como mencioné arriba.

Ahora, las preguntas que me quedan dando vueltas:

  • ¿Alguien lo usó en producción? Y si es así, ¿qué problemas le encontraron en el camino que yo todavía no vi?
  • ¿O directamente es mejor irse por lo simple y aceptar el trade-off — Herencia de Tabla Única con el detalle específico en un jsonb — en vez de toda esta vuelta con claves compuestas?
reddit.com
u/Ok_Two_2900 — 24 days ago
▲ 2 r/SpringBoot+2 crossposts

I feel scammed by the Dependency Inversion Principle (DIP)

I must obviously be wrong or still not fully understanding this, but I was always sold on the idea of "solve your circular dependencies with dependency inversion," and that's what I did in my Laravel project (which is a modular monolith):

I have several modules, but for example I have worker (the module in charge of employees) and service (the module in charge of the services those employees provide). The thing is, I have a circular dependency: worker depends on service and service depends on worker. Why? Well, in short, so that a service can know which employees perform it, and so that an employee can know which services they perform (a classic many-to-many relationship).

Obviously this is a circular dependency. How did I solve it? With the Dependency Inversion Principle (DIP), by creating a "third entity" (I'll call it core for simplicity), which holds the public contracts for Service and Worker — pure interfaces, with no implementation, that both modules depend on instead of depending directly on each other:

Modules/
├── worker/
│   └── src/
│       └── (concrete implementation, depends on core)
├── service/
│   └── src/
│       └── (concrete implementation, depends on core)
└── core/
    └── src/
        └── Ports/
            ├── WorkerContract.php
            └── ServiceContract.php

So, worker depends on core, and service depends on core. I broke the circular dependency, or at least that's what I thought, until...

Artisan commands stopped working, my RAM maxed out, and everything was broken. After debugging for 2 hours, I discovered there was an infinite loop: to build WorkerService, Laravel first needs to build an Action that depends on the ServiceOffered contract, meaning it needs to build a complete ServiceOfferedService. But to build ServiceOfferedService, it needs to build an Action that depends on the Worker contract, meaning it needs WorkerService back again. Infinite circle, even though both sides only depended on interfaces and not on concrete classes.

At that moment I felt scammed. Hadn't I already solved that? I feel like dependency inversion just disguises the circular dependency, or rather, moves it elsewhere. And look, I'm not saying it's a bad pattern — it's actually great, because it lets you change a module's internal implementation without breaking the other one, and if tomorrow you split the modules into separate repos, neither module ends up tied to the other's internal code. That works great. But this whole "solving circular dependencies" thing people always say, hmm, I honestly don't see it — because the runtime instantiation cycle was still there, completely intact, interfaces or not. In fact, digging further, I found out that Spring (the Java DI framework) even has an exception with its own dedicated name for this, BeanCurrentlyInCreationException, that's how common this problem is — and interfaces don't save you from it either.

From what I've seen, there are ways to solve this: one would be making the dependency "lazy" (instead of requesting it in the constructor, resolving it inside the method that actually uses it, only when it's really needed), and another would be using setters instead of constructor injection (having the object be born "empty" and having the dependency filled in later, once both objects already exist separately). But still, that doesn't change the fact that I feel "scammed" by how this pattern gets sold.

  1. Can someone explain if I did something wrong? Or what am I not understanding?
  2. Why didn't Laravel warn me somehow about this problem with a clear exception? It worries me, for example, if another dev touches the code without knowing A already depends on B, and unknowingly creates a dependency from B back to A — especially since Laravel doesn't warn you about it at all (unlike Spring, which does throw a specific exception for this).
reddit.com
u/Ok_Two_2900 — 29 days ago
▲ 27 r/devsarg+2 crossposts

¿Para qué carajo sirve la Order API de Mercado Pago si no te deja reintentar un pago?

Vengo de usar Stripe, donde tenés el PaymentIntent: creás uno solo por la compra, y si un intento de cobro falla (tarjeta rechazada), le asociás un charge nuevo — todos los intentos quedan prolijamente bajo el mismo objeto. Quería replicar exactamente ese comportamiento con la Order API de Mercado Pago, usando processing_mode: manual, que supuestamente te da más control sobre el ciclo de vida de la orden.

Perfecto, pensé, entonces si el pago sale rechazado, reemplazo la transacción por otra tarjeta y proceso de nuevo. Ese es literalmente el caso de uso para el que pensé que estaba diseñado el modo manual.

Mentira. Probé de todas las formas posibles: DELETE de la transacción, PUT para cambiarle la tarjeta, antes y después de procesar, con distintos capture_mode, con distintos tipos de rechazo. Conclusión: en cuanto la Order queda en estado failed, se cierra en firme (con suerte tenés una ventana de UN par de segundos antes de eso, nada confiable). Después: 400 invalid_order_mode_for_operation para todo.

Y ojo que la doc de Mercado Pago habla de "multiple payment transactions" por todos lados como si fuera una feature central de la Order API.

¿Alguien laburó de verdad con la Order API y me puede confirmar esto? ¿O hay alguna forma real de reintentar sobre la misma Order que se me esté escapando?

reddit.com
u/Ok_Two_2900 — 1 month ago

I have a modular monolith in Laravel where each module is a private Composer package. Several people recommended migrating to Symfony. Opinions?

Context

Tolo is a platform for creating websites focused on specific industries. The idea is that a barbershop using it feels like it was made for barbershops, a gym feels like it was made for gyms. I don't want it to feel generic like Wix. That implies very specific features per industry: gyms can create routines for their clients, courses can handle exams, grades, online content, etc.

There are two types of websites that can be created on Tolo: services (barbershops, gyms, clinics, etc.) and ecommerce (any business that sells products).

Why I migrated to a modular monolith

I started with a traditional monolith and reached a point where I had a Services/ folder with everything together, and touching any specific feature for one industry meant mixing with code from other industries. It wasn't sustainable.

I migrated to a modular monolith to be able to work on industry-specific features without touching other modules' code, and to have clear boundaries between domains.

The architecture

I use internachi/modular which almost automatically converts each module into an independent Composer package. The module structure is this:

Modules/
├── core/
├── auth/
├── platform/
├── client/
├── media/
├── ecommerce/
│   ├── products/
│   ├── shipping/
│   ├── offers/
│   └── // etc
├── domains/
├── appearance/
├── notifications/
├── payments/
├── integrations/
├── subscriptions/
├── distributors/
├── admin/
└── services/
    └── Modules/
        ├── branches/
        ├── reservations/
        ├── workers/
        ├── gallery/
        ├── dashboard/
        ├── services-offered/
        ├── reviews/
        └── industries/
            ├── base/
            ├── barber/
            ├── gym/ // routines, memberships, etc
            ├── courses/ // exams, grades, online courses, etc
            // +20 more industry types
            └── other/

The role of core and why it exists

When for example reservations needs to know if a time slot falls within an employee's working hours, it needs to communicate with workers. The obvious solution would be for reservations to have a require to workers in its composer.json. But that would download all of workers' code into reservations' vendor folder, breaking the isolation I'm looking for.

The solution was to create core, a module that centralizes the contracts of all modules. reservations depends on core, not on workers directly. core provides WorkerContract, and in workers' ServiceProvider I bind that contract to the real implementation. Each module only sees the interface, not the other's code.

This idea is directly inspired by illuminate/contracts in Laravel: a package that contains only interfaces with no implementation whatsoever. I tried to replicate that same pattern at my project level.

The problem with Laravel

Several devs pointed out something I was already noticing myself: with this architecture I'm constantly fighting against Laravel. The framework's magic (facades, automatic resolution, etc.) makes static analysis much harder. I was using PHPStan at level 9 and kept spending more time patching Laravel's magic than fixing real bugs. I eventually lowered the level, but the underlying problem remains: Laravel wasn't designed to be used this way.

Why people recommended Symfony

Several people commented that Symfony is more explicit by design, has no magic, that DTOs and strong typing are first-class citizens, and that it has a Bundles system that is basically what I'm already doing but with native framework support. Also that PHPStan at high levels works much better because there's nothing to "patch".

The honest questions

Has anyone worked with Symfony and can confirm or deny this? Do Bundles actually solve what I'm describing? Is the development experience with DTOs and static analysis noticeably better?

Also: does this architecture make sense given the context, or is there something you would change structurally?

My main hesitation about Symfony is basically that it's not as popular as Laravel and there are fewer resources, community packages, and tutorials available. Is that a real problem in practice or is it an overestimated concern?

reddit.com
u/Ok_Two_2900 — 2 months ago
▲ 1 r/softwarearchitecture+1 crossposts

I gave little context in my previous post. Here's my full architecture: a modular monolith in Laravel where each module is a private Composer package inspired by how Laravel itself is built internally. Opinions welcome.

Context

Tolo is a platform for creating websites focused on specific industries. The idea is that a barber who uses it feels like it was made for barbers, a gym feels like it was made for gyms. I don't want it to feel generic like Wix. That implies very specific features per industry: gyms can create routines for their clients, courses can handle exams, grades, online content, etc.

There are two types of websites that can be created on Tolo: services (barbershops, gyms, clinics, etc.) and ecommerce (any business that sells products).

Why I migrated to a modular monolith

I started with a traditional monolith and reached a point where I had a Services/ folder with everything together, and touching any specific feature for one industry meant mixing with code from other industries. It wasn't sustainable.

I migrated to a modular monolith to be able to work on industry-specific features without touching other modules' code, and to have clear boundaries between domains.

The architecture

I use internachi/modular which almost automatically converts each module into an independent Composer package. The module structure is this:

Modules/
├── core/
├── auth/
├── platform/
├── client/
├── media/
├── ecommerce/
│   ├── products/
│   ├── shipping/
│   ├── offers/
│   └── // etc
├── domains/
├── appearance/
├── notifications/
├── payments/
├── integrations/
├── subscriptions/
├── distributors/
├── admin/
└── services/
    └── Modules/
        ├── branches/
        ├── reservations/
        ├── workers/
        ├── gallery/
        ├── dashboard/
        ├── services-offered/
        ├── reviews/
        └── industries/
            ├── base/
            ├── barber/
            ├── gym/ // routines, memberships, etc
            ├── courses/ // exams, grades, online courses, etc
            // +20 more industry types
            └── other/

The role of core and why it exists

This is the part that generated the most questions. When for example reservations needs to know if a reservation's time slot falls within an employee's working hours, it needs to communicate with workers. The obvious solution would be for reservations to have a require to workers in its composer.json. But there's the problem: that would download all of workers' code into reservations' vendor folder, breaking the isolation I'm looking for.

The solution I found was to create core, a module that centralizes the contracts of all modules. reservations depends on core, not on workers directly. core provides WorkerContract, and in workers' ServiceProvider I bind that contract to the real implementation. Each module only sees the interface, not the other's code.

This idea was inspired by Laravel's own architecture. Laravel is divided into independent Composer packages, and has illuminate/contracts, a package that contains only interfaces with no implementation whatsoever. Any package in the ecosystem can depend on it to know how to interact with the framework without downloading the entire implementation. I tried to replicate that same pattern at my project level with core.

The future vision and why I chose this approach

One important part I didn't mention in the previous post: I'm building Tolo with the vision of putting together a team. And that vision is what guided many of the architecture decisions.

The idea is that each module eventually has its own independent repository, managed through AWS CodeArtifact or similar. Each team member or group would manage their module autonomously. When they make a change, if all tests pass the new version gets published and the server consumes the updated packages. A developer working on reservations doesn't need to see or touch the code of payments. Everyone works in their domain, with clear contracts, and the rest is a black box.

The fact that internachi/modular converts each module into an independent Composer package almost automatically was what convinced me to use it. It gave me the structure I needed to scale toward that team model without having to rewrite everything when the time comes.

The idea itself is to find a middle ground between a traditional monolith and microservices. Everything is well separated by domain, but everything runs on the same server, avoiding the network communication that microservices use. To me that sounded like the best of both worlds.

I understand it's not a typical approach and that it has complexity. That's why I want to hear opinions now that you have the full context.

The honest question

Is this architecture reasonable given the context, or is it genuinely bad? I'm asking seriously because honestly I don't know if I have impostor syndrome or I'm just bad. I want to know. Total honesty welcome.

reddit.com
u/Ok_Two_2900 — 2 months ago
▲ 6 r/PHP

PHPStan level 9 + DTOs in modular Laravel — it's driving me crazy and I need to understand what I'm doing wrong

Edit: I didn't give enough context in this post. I made a follow-up with my full architecture and the reasoning behind it: here

Hi everyone. I started using PHPStan level 9 in a Laravel project and ended up in a never-ending DTO rabbit hole. I want to understand if I'm doing it right or overcomplicating things unnecessarily.

The architecture

I use internachi/modular to separate the project into modules, where each module is an independent Composer package. For example, the auth module lives in Modules/auth with its own composer.json, its own routes, migrations, etc.

Inside each module I have this structure:

Modules/
├── core/
│   └── src/
│       ├── Contracts/
│       └── DTOs/
│
├── platform/
│   └── src/
│       ├── Actions/
│       ├── Contracts/
│       ├── DTOs/
│       ├── Models/
│       └── Services/
│
└── auth/
    └── src/
        ├── Actions/
        ├── Contracts/
        ├── DTOs/
        ├── Events/
        ├── Http/
        │   ├── Controllers/
        │   └── Requests/
        ├── Models/
        ├── Repositories/
        └── Services/

The flow is: Controller → Service → Action → Repository. Services implement Contracts, Actions have the actual logic.

The problem with DTOs

I have these DTOs in the auth module:

  • LoginDTO
  • RegisterDTO
  • CreateUserDTO
  • UpdateProfileDTO
  • UpdateUserDTO
  • SocialiteRegisterDTO

And in a core module I have CreateEntityDTO.

The problem isn't that I don't understand what they're for. I understand they decouple layers, are type-safe, and PHPStan loves them. The problem is the cost of creating them.

Writing an Action takes me 30 minutes. Writing the DTOs that Action needs takes me 3 hours. Why? Because I have to:

  1. Create the DTO
  2. Update the Contract
  3. Update the Service
  4. Update the Action
  5. Update the Controller
  6. Update the FormRequest

And if PHPStan keeps complaining in the middle of all that, start over.

The concrete case that broke my brain

I have a LoginRequest (Laravel FormRequest) that validates the fields:

php

class LoginRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'user'         => 'required|string',
            'password'     => 'required|string',
            'id_sucursal'  => 'nullable|integer',
            'id_ecommerce' => 'nullable|integer',
        ];
    }
}

And in the controller I do:

php

public function login(LoginRequest $request): JsonResponse
{
    $result = $this->auth->login(
        (string) $request->user,
        (string) $request->password,
        $request->id_sucursal !== null ? (int) $request->id_sucursal : null,
        $request->id_ecommerce !== null ? (int) $request->id_ecommerce : null,
    );
}

PHPStan level 9 explodes with "Cannot cast mixed to string" on every line. The FormRequest ALREADY validated that user is a string. But PHPStan doesn't connect validation rules to types — it reads __get() and sees mixed.

The real question I can't answer

Where does it make sense to use DTOs and where doesn't it? I feel like I'm doing more than necessary, or maybe this is normal and I'm just not used to it. I don't know if the problem is my architecture, my judgment when creating DTOs, or simply that PHPStan level 9 in Laravel is just this tedious.

Has anyone been through this? How do you decide when to create a DTO and when not to?

Also, I'd love to hear opinions on the modular architecture where each module is a "private" Composer package. The idea was to imitate how Laravel itself is built internally — each module with its own composer.json, its own contracts, its own implementations.

reddit.com
u/Ok_Two_2900 — 2 months ago
▲ 1 r/CharruaDevs+1 crossposts

PHPStan nivel 9 + DTOs en Laravel modular — me está volviendo loco y necesito entender qué estoy haciendo mal

Hola gente. Empecé a usar PHPStan nivel 9 en un proyecto Laravel y terminé en un rabbit hole de DTOs que no termina más. Quiero entender si lo estoy haciendo bien o si me estoy complicando la vida innecesariamente.

La arquitectura

Uso internachi/modular para separar el proyecto en módulos, donde cada módulo es un paquete Composer independiente. Por ejemplo el módulo de auth vive en Modules/auth con su propio composer.json, sus propias rutas, migraciones, etc.

Dentro de cada módulo tengo esta estructura:

Modules/
├── core/
│   └── src/
│       ├── Contracts/
│       └── DTOs/
│
├── platform/
│   └── src/
│       ├── Actions/
│       ├── Contracts/
│       ├── DTOs/
│       ├── Models/
│       └── Services/
│
└── auth/
    └── src/
        ├── Actions/
        ├── Contracts/
        ├── DTOs/
        ├── Events/
        ├── Http/
        │   ├── Controllers/
        │   └── Requests/
        ├── Models/
        ├── Repositories/
        └── Services/

El flujo es: Controller → Service → Action → Repository. Los Services implementan los Contracts, los Actions tienen la lógica real.

El problema con los DTOs

Tengo estos DTOs en el módulo de auth:

  • LoginDTO
  • RegisterDTO
  • CreateUserDTO
  • UpdateProfileDTO
  • UpdateUserDTO
  • SocialiteRegisterDTO

Y en un módulo core tengo CreateEntityDTO.

El problema no es que no entienda para qué sirven. Entiendo que desacoplan capas, que son type-safe, que PHPStan los ama. El problema es el costo de crearlos.

Hacer un Action me toma 30 minutos. Hacer los DTOs que necesita ese Action me toma 3 horas. ¿Por qué? Porque tengo que:

  1. Crear el DTO
  2. Actualizar el Contract
  3. Actualizar el Service
  4. Actualizar el Action
  5. Actualizar el Controller
  6. Actualizar el FormRequest

Y si en el medio PHPStan sigue quejándose, empezar de nuevo.

El caso concreto que me rompió la cabeza

Tengo un LoginRequest (FormRequest de Laravel) que valida los campos:

class LoginRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'user'         => 'required|string',
            'password'     => 'required|string',
            'id_sucursal'  => 'nullable|integer',
            'id_ecommerce' => 'nullable|integer',
        ];
    }
}

Y en el controller hago:

public function login(LoginRequest $request): JsonResponse
{
    $result = $this->auth->login(
        (string) $request->user,
        (string) $request->password,
        $request->id_sucursal !== null ? (int) $request->id_sucursal : null,
        $request->id_ecommerce !== null ? (int) $request->id_ecommerce : null,
    );
}

PHPStan nivel 9 explota con "Cannot cast mixed to string" en cada línea. El FormRequest YA validó que user es string. Pero PHPStan no conecta las reglas de validación con los tipos — lee __get() y ve mixed.

La pregunta real que no puedo responderme es dónde corresponde usar DTOs y dónde no. Siento que estoy haciendo más de lo necesario, o quizás es lo normal y simplemente no estoy acostumbrado. No sé si el problema es mi arquitectura, mi criterio para crear DTOs, o simplemente que PHPStan nivel 9 en Laravel es así de tedioso.

¿Alguien pasó por esto? ¿Cómo definen cuándo crear un DTO y cuándo no?

De paso, me gustaría saber qué opinan sobre la arquitectura modular donde cada módulo es un paquete Composer "privado". La idea fue imitar cómo está construido el propio Laravel internamente — cada módulo con su composer.json, sus contratos, sus implementaciones.

reddit.com
u/Ok_Two_2900 — 2 months ago