← Back to projects

Project 01 / Case study

Backend and product · actively in development

From a contact spreadsheet to a CRM that knows who it is writing to.

An outbound lead generation SaaS built from scratch for a circuit of events: multi-company, with deliverability under watch and a model that classifies industries without leaving the server. It is the project where I have learned most about systems, about email and about limits.

103REST endpoints
49JPA entities
135Unit tests
6external APIs integrated
RoleDesign, backend, frontend and operations
JourneyFrom scratch, in continuous evolution
TechnologiesJava 21, Spring Boot 3.5, React 18, MariaDB, Docker
01

The challenge

Sponsorship outreach lived in spreadsheets: nobody knew who had been written to, emails bounced without anyone seeing it and domains burned out in two campaigns. What was needed was a single source of truth for tens of thousands of records, with sending rules that would not sink the sender's reputation.

02

The solution

A modular monolith with isolation per entity, a send queue on the database itself, warm-up profiles per account and webhooks that return reality (delivery, open, bounce, complaint, unsubscribe). Classification by industry is done by a local model, and every run is logged and reviewable.

03

What I learned

That the interesting work was not the CRUD but deliverability and data isolation. And that secrets management was my weak point: a configuration file that ended up where it should not have cost me far more than a checklist ever would.

Architecture

A monolith with clear layers and a queue inside.

Monorepo with backend, frontend, AI experiment and deployment package. The backend separates 23 controllers, 44 services and 32 repositories over 49 JPA entities, with 122 Java records acting as the API's contracts (97 of them with a Request or Response suffix).

Multi-company isolation is the decision that drags everything else along. The Entity → Project → Users hierarchy hangs off an authorisation service that checks, on every operation, whether the row belongs to the user's entity. Companies do not carry an entity column: they are isolated through the campaign that moves them, because the same record can appear in two campaigns with different treatment.

Sends do not go through a broker: they are queued in a table and a scheduler reviews it every second, with retry and automatic recovery of stuck sends after sixty seconds. If the process dies halfway through a campaign, the queue is still there when it starts up and the work is not duplicated.

Around that core: address validation before writing, a per-entity blocklist that cancels what is queued, one-click unsubscribes with a signed token, templates with attachments and more than fifteen variables, and periodic synchronisation of each user's mail over OAuth with the refresh tokens encrypted with AES-256-GCM in the database.

Campaign → mobilisation (no duplicates, off the blocklist) → send profile: window, pause, daily quota, ramp → table queue → worker (concurrency 5, 1 s cycle) → email provider → webhook: delivery · open · bounce · complaint · unsubscribe → single inbox with threads, assignment and status
  • Backend

      Java 21Spring Boot 3.5Spring SecuritySpring Data JPAActuatorLombokMaven
  • Data

      MariaDB49 entities109 foreign keys27 schema evolution scriptsCHECK constraints
  • Frontend

      React 18TypeScript 5.7Vite 6DOMPurifyCustom CSS
  • Integrations

      Transactional emailProgrammatic DNSAddress validationGoogle OAuthEmail APIOllama
  • Operations

      DockerNginxHealthchecksThree release branches

Decisions

What I chose, and what I deliberately left aside.

Four decisions that can be argued with. Each one with its alternative and its bill.

01

Server session with CSRF, not JWT

An SPA served from the same origin does not need to hand out tokens. I ruled out JWT in local storage: a script injection steals it, and truly revoking it requires a blocklist that cancels the advantage. The price is session affinity and cookies; in exchange, session state lives where I am in charge.

02

Table queue instead of a broker

Thousands of sends a day, a single active environment and the need to see the queue from the interface itself and rescue stuck sends. I ruled out Redis and RabbitMQ: they are another machine to watch and another way to fail. The real cost is that there is no guaranteed ordering and no multicast, and that the queue competes with the database for resources.

03

Programmatic permissions instead of annotations

5 roles and around 13 permissions, applied from a service that checks entity membership on every call (118 control points). I ruled out method annotators: a permission that depends on which company the row belongs to does not fit in a static label on the method.

04

Local model, auditable results

Classifying the industry of some 260,000 records against a paid API was unworkable. A small model on my own GPU does it at zero marginal cost, with low temperature and JSON output. Every run is stored with its input and its result so it can be reviewed afterwards. What is missing: the agent that decides the sponsorship level with evidence is designed on paper and not implemented, and the commercial provider is still wired up through variables with no code behind it.

Results

What can be counted with the code open in front of you.

Figures measured on the repository, not impressions.

  • 21,733lines of Java in 297 files, 3,513 of them tests
  • 9,468lines of TypeScript and TSX
  • 4,531lines of SQL in 27 schema evolution scripts
  • 7,116lines of living internal documentation
  • 135tests in 30 files: unit tests with Mockito and two integration tests
  • 3,000sample records in the development database, with the full schema

What I would do differently

The interface is a single-page application of 6,400 lines with no router and not a single test: it works, but it is the place where a second person could not get involved. It is missing continuous integration and a deployment that does not depend on me remembering the order. And the episode of the configuration file with credentials in the deployment package ended in key rotation and one simple rule: example without values and secrets only on the host. The boring part of engineering is, in the end, the part that grades you.