← All articles

Architecture ·

Designing and shipping a two-sided marketplace as its only engineer

MMA Finds connects fighters, managers, promotions and their staff. The architecture behind a production marketplace on iOS and Android: a core backend with explicit domains, a Kafka-driven notification service, payments that cannot double-credit, and an analytics dashboard for the business.

By , software architect

Context

MMA Finds is a vertical marketplace for the MMA industry that I co-founded in 2024. Promotions discover fighters and send fight offers. Fighters and their representatives find opportunities, negotiate, submit documents and build a reputation. I am the only engineer on four codebases:

Component Stack Role
Core backend Spring Boot 3, Java 17, PostgreSQL Domain, API, payments, identity integration
Notification service Spring Boot 3, Java 24, PostgreSQL, Kafka Turns domain events into push and email
Mobile app React Native (Expo) iOS and Android client
Analytics dashboard React, Recharts Business metrics for the founders

Scale of the codebase: 500+ Java classes in the core, 230 Flyway migrations, 175 app screens.

Being the only engineer shaped every decision below. Each moving part gets paid for twice: once to build it, and again every time it can break.

Decision 1: one core backend, one service split out

The core stays a single Spring Boot application, with each business area in its own package: fighters, offers, negotiation, submissions and documents, representation, verification, finance, badges, referral and analytics. With one engineer, splitting these into separate services would have added network failure modes and deployment overhead without solving any real problem.

Notifications were split out. They have a different load profile: spiky and fan-out heavy. They have different failure tolerance: a delayed push is acceptable, a failed offer is not. And they have their own data, such as device tokens and delivery status. The core publishes events to Kafka. A separate notification service consumes them, so a slow push provider or an email outage never blocks an API request.

Decision 2: model the business, not the screens

  • Four roles with distinct permissions: fighter, manager, promotion and promotion employee.
  • Fight offers as the core domain. There are three offer types, each with its own flow: public, exclusive and multi-candidate. Around them sit negotiation, document submission with deadlines, and participation confirmation or rejection.
  • Representation as a relationship. A manager representing a fighter goes through a request and approval flow. It is not a free-text field, because permissions, offers and messaging depend on it.
  • Verification and e-signature. Account verification, plus a DocuSign integration for signing documents.

Decision 3: identity delegated to Keycloak

  • Keycloak with OAuth 2.0 / OIDC and PKCE for the native app. Sign-in with email, Google and Apple.
  • Spring Security validates JWTs on every API call.
  • Delegated access for promotion employees. Staff act on behalf of a promotion without owner-level operations.

Building auth in-house is one of the easiest things to get dangerously wrong. Delegating it to a proven identity server removed that risk.

Decision 4: payments that cannot double-credit

  • Stripe PaymentIntent and SetupIntent, with saved payment methods and webhooks. Service and success fees are calculated server-side.
  • In-app credit packs on both App Store and Google Play.
  • Exactly-once crediting. Each store purchase is upserted by its store transaction ID. A separate conditional update then marks it as granted and returns 1 the first time and 0 on every repeat, and credits are added only on 1. A retried request, a double tap or a replayed call gets a duplicate response instead of free credits. No read-then-write race is involved.
-- simplified
UPDATE iap_payments SET credits_granted = 1
 WHERE platform = :platform AND transaction_id = :txId AND credits_granted = 0;
-- 1 row → grant credits; 0 rows → already granted, respond "duplicate"

Decision 5: notifications as event-driven scenarios

  • The core publishes domain events to a Kafka topic.
  • The notification service routes each event to one of 22 scenario handlers: a new offer, a document submitted, all documents in, a deadline approaching or missed, a negotiation or participation response, credits earned, a new message, and so on.
  • Delivery goes out as push through Expo and FCM, and as email.
  • Failed messages go to a dead-letter topic with its own consumer, instead of being lost or blocking the partition.

Adding a new notification means adding a new handler in one service. The core never changes.

Decision 6: boring, cheap, observable infrastructure

  • Docker behind Nginx with TLS on Hetzner, with separate local, dev, UAT and prod configurations.
  • Prometheus metrics through Actuator and Loki log shipping from day one.
  • React Native (Expo) app with deep links (mmafinds:// and links.mmafinds.com) into profiles, offers and invitations. Store releases, signing and reviews were all handled end to end.
  • A React analytics dashboard gives the founders payments, fees and engagement metrics without SQL.

No Kubernetes, unlike the enterprise platforms I work on. For one engineer at this load, Docker on a VM keeps operations in proportion to the team.

Result

  • A production marketplace live on iOS and Android with thousands of users, designed, built and operated by one engineer.
  • A single-day spike of 700 registrations within three hours, served by the same Docker-on-Hetzner setup.
  • Notifications are isolated from the request path, and purchases cannot be credited twice.
  • The seams to grow along are already in place: packages per domain and events on Kafka.

Product: mmafinds.com

More articles