Backend In production

Vecinly: a SaaS for homeowners’ associations

Vecinly is the all-in-one platform for managing homeowners’ associations: incidents, bookings, accounting, polls, suppliers, tasks and communication. I built all of it and I run it: the API, the web app, the two mobile apps and the infrastructure. It is in production, with paying customers.

Role
Founder and sole developer
Since
February 2026
Backend
Java · Spring Boot · PostgreSQL
Clients
React · React Native (Expo)
Visit vecinly.comThe live website, in 11 languages

In numbers

~590use cases, from signing up a community to charging its subscription
~230domain models that hold the business rules, depending on nothing
11languages and 11 countries, each with its own property law
~4,680backend tests, against a real PostgreSQL

01

Context

The problem

The people who suffer a community’s management are the president, who is not paid for it, and the property manager, who looks after dozens of communities at once. Incidents get lost in a WhatsApp group, the accounts live in a spreadsheet only its author understands, and nobody knows where anything stands without asking.

A product for that has to serve very different profiles (residents, presidents, property managers, suppliers and caretakers) without any of them seeing what is not theirs to see. And since it holds meeting minutes, fees and personal data of residents in 11 countries, security comes first.

Figure 0. The vecinly.com home page, with its animation. The site is prerendered in all 11 languages so that search engines and AI engines, which do not run JavaScript, can read it.

What it does

Around 50 business domains, six of which carry a community’s day-to-day:

  • IncidentsOn a board by status: opened, assigned and closed, and whoever reported it can see where it stands.
  • AccountingTransactions with their budget lines and balance, instead of a spreadsheet.
  • InvoicingThe community’s invoices, linked to the accounts and to recurring billing.
  • ChatResidents, president, property manager and suppliers talk inside the product, in real time.
  • Polls and meetingsOne-click polls, meeting notices and follow-up of the meetings.
  • BookingsThe court or the function room is booked from the app, with the calendar in plain sight for everyone.

02

Architecture

Three clients, a single API

The web app, the iOS app and the Android app talk to the same service. Business rules and permissions live in one place: a rule is written once and reaches all three clients, and the web and the apps cannot behave differently.

What drives it What it uses Adapters translate outside to inside Use cases ~590 in total ports 165 outbound Domain ~230 models Web iOS app Android app Cloudflare Tasks ~40 scheduled REST Live channel Scheduler Persistence PostgreSQL Encryption KMS Payments Stripe Invoicing Holded Identity Identity Files Storage Notifications Email and push dependencies point inwards
Figure 1. Vecinly’s API, from the inside. The business rules live in the domain, which depends on nothing. The use cases orchestrate them and talk to the outside only through ports; the adapters, on the edge, connect those ports to the web, the apps and the providers. Approximate figures from the code.

A hexagonal monolith, for this phase

In an MVP what matters is validating the idea: development should be fast, deploying should be simple and costs should stay low. A well-built monolith, with clear boundaries between domains, is a perfectly valid choice for that: one codebase, one deployment and no network between the pieces. Microservices are the best option when the situation calls for them, and at this stage they would have been a bad idea: a lot of complexity for a product that still had to prove anyone wanted it.

LayerWhat it holdsRule
DomainEntities and business rules (~230 models)No Spring, no database
Use casesThe application’s actions (~590 in total)Talk to the outside only through ports
AdaptersREST, WebSocket, PostgreSQL, Stripe, Holded, KMSImplement the ports; can be swapped without touching the domain

The price is discipline: the boundaries between domains depend on design and review, not on the network, and architecture tests keep watch over them. That discipline is also what makes the next step possible.

Next: towards microservices

The phase has changed. There are now three goals: gaining users, which will grow resource consumption and its cost; earning revenue, which will pay for better infrastructure; and extending the product with features that go beyond validating it. With those goals it is time to start moving towards microservices, and that is what I am planning.

Complexity and workload go up, but resource usage improves a lot, especially in a serverless environment. With a monolith, the whole application scales as one block even though most of what it does is rarely used. Split up, each piece gets its own policy: services used only now and then scale to zero and cost nothing while nobody uses them, and those that must always respond instantly, such as sign-in or chat, keep one instance running to avoid cold starts. Since every domain already talks to the rest through ports, splitting one off means swapping an adapter, not rewriting its logic.

Monolith · one single block Services · the core organises communication Core Chat Incidents Accounting Bookings Documents Payments Meetings always on scales to zero
Figure 2. Breaking the monolith. The block splits along the boundaries between domains. One piece, the core, stays in the centre and organises the communication; each service talks only to it and scales its own way.

The use cases, by area

Each use case answers a specific question from a specific profile: “report an incident”, “approve a supplier’s quote”, “call a meeting”. This is how they break down:

AreaMain use casesOperations
Community and onboardingCreate the community with a wizard, shape its entrances and units, import ownership shares, invite owners and tenants, assign a property manager~130
Maintenance and suppliersReport and track incidents, assign tasks, request work, receive and approve suppliers’ quotes and invoices~120
AccountingAnnual budget, fees by ownership share, expense groups, transactions and balance~65
Subscription and paymentsTake out a plan, change or cancel it, charge and issue the invoice~55
CommunicationChat, announcements, emergency notices and moderation~40
Meetings and decisionsCall meetings, legally valid votes and polls~35
DocumentsFolders for the community and for each owner, legal documents~30
Common areasBookings and parking spaces~20
CaretakersOnboarding, assignment to communities and their day-to-day~20

Approximate figures, counted in the code in October 2026. An operation is an API action; one use case can serve several.

They do not all need the same level of control, but the essential ones do: which profile can do what, with which data, and what happens next. Here are a few:

Vecinly Residents Professional management Book a common areaReport an incidentVote in a meetingCall a meetingApprove a quoteIssue the feesSend a quoteUpload an invoice Resident President Property manager Supplier
Figure 3. Some essential use cases and who uses them: residents on the left, professional management on the right. Each profile only sees its own.

Two pieces, from the inside

Behind every screen there are many use cases at once. Signing up a community is a flow that chains country, building type, details, structure and plan, and out of it come the units, the tier and the subscription. Accounting is a dashboard where the budget, fees by ownership share, expenses and each unit’s payments meet.

Figure 4. The wizard for signing up a community, filling itself in: country, building type, details, structure, summary and plan. It is the platform’s real component, with a made-up community.
Accounting dashboard: bank balance, expected income and expenses, reserve fund and annual cash forecast.Annual budget: recurring expenses, one-off expenses and extra income, with their budget lines.Payment grid: each unit and its fee for every month, paid, pending or overdue.
Figure 5. The accounting dashboard, tab by tab: dashboard, annual budget and payment grid. Captures of the vecinly.com sample community, with made-up data.

03

Isolating each community

One database for all, four layers of isolation

Hundreds of communities share a database, and a resident must never see anything from another one. A database or a schema per customer would multiply migrations and connections with every new community. So isolation is logical, per community, and rests on four layers:

LayerHow
AuthorisationEvery route declares which role and which plan allow it; anything that declares nothing is denied
QueriesEvery query is scoped to one community, and the indexes are designed for that
EncryptionPractically everything is stored encrypted, with its community’s key
Real timeSubscriptions to the chat and to notices are denied by default

One encryption key per community

Practically all information is encrypted, including what European regulations do not require to be encrypted: personal data, conversations, documents, accounts. Only what truly does not matter at all is left unencrypted. A master key that never leaves the KMS wraps the data keys, and each community has its own. The community’s identifier goes into the encryption as associated data: a ciphertext is useless in another community.

Master key never leaves the KMS Key · community A Community A data resident · 9f3a… Key · community B Community B data resident · 7d21…minutes · c5e9…fee · 1a6f… Key · community C Community C data resident · b4d0…minutes · 0e7c…fee · 93a2… fee · AAD = A 9f3a·c1e7·08b2 €1,240.00 won’t decrypt authenticated encryption · the community’s identifier is part of the encryption
Figure 6. One key per community. Practically everything is stored encrypted, and an encrypted value from one community is useless in another: the community’s identifier is part of the encryption. If the system key does not arrive from the KMS, the application does not start.

If the key does not arrive from the KMS, the application refuses to start: it fails closed. And to be able to search for an encrypted value without decrypting it, a cryptographic fingerprint (a blind index) is stored next to it: searches and uniqueness go through it.

The life of a request

Every request goes through several checks before it touches any data, and the community’s key is only loaded from the KMS when needed.

Origin where from? Volume request limit Identity who is it? Rule no rule, denied Permission role and plan? Key its community’s Their data encrypted Laura · resident see her fees A request that may pass
Figure 7. The life of a request. Before it touches any data, every request goes through six checks. One with permission reaches its community’s data, which is decrypted with that community’s key; one that lacks the role or the plan is stopped by the permission check.
One use case, end to end Resident API Use case Data alt [not theirs to do] report an incident who is it, what may they do? denied save, encrypted, in their community incident reported
Figure 8. One use case, end to end. The rules live in the use case: the web and both apps go through the same place.

04

Payments

Stripe charges, Holded invoices

Vecinly charges subscriptions by number-of-units bands in several EU countries and has to issue legal invoices. Stripe takes the payment, with Stripe Tax. The application generates the invoice in Holded once the payment is confirmed, with the exact VAT Stripe charged, to comply with VeriFactu. Cards never go through my server.

Charging only once

Stripe retries its payment notices and may send them out of order. So when one arrives, its signature is the only thing checked, it is stored with a unique key and answered straight away; the real work happens separately, through a queue.

no yes yes no yes no Notice arrives from Stripe Save and reply with a unique key Enqueue the real work Process separately in the background Activate the plan for the community Issue the invoice in Holded Reject not from Stripe Discard no second charge Retry if it persists, review Valid signature? Already received? Applied correctly? new notice
Figure 9. Charging only once. A new notice goes all the way to the active plan and the invoice; if Stripe repeats the same notice, the unique key stops it before it touches anything. If something fails during processing, it is retried, and if it keeps failing it goes to review.

When it is processed, it is checked for having been processed already and marked as processed in the same transaction that applies the change, so that a failure halfway does not leave a payment halfway. And if the subscription stops being healthy, the community goes back to the free plan.

A payment and its invoice Property manager Stripe API Use case Holded pay the subscription payment confirmed already received it? activate the plan issue the invoice invoice
Figure 10. A payment and its invoice. Only a confirmed payment is invoiced, and a repeated notice is never applied twice.

05

The app

One codebase, two apps

Residents and presidents need one app; property managers, suppliers and caretakers need a very different one. Both come from a single codebase in Expo and React Native: Vecinly and Vecinly Pro, on iOS and Android, with the same sign-in. The apps sell nothing, because of the stores’ rules: payment and creating communities happen on the web.

Download on the App Store Get it on Google Play
Vecinly app: community overview with incidents, notices and tasks. Overview
Vecinly app: bookings of common areas on a calendar. Bookings
Vecinly app: maintenance tasks by status. Tasks
Vecinly app: the community’s accounts, with balance and transactions. Accounting
Vecinly app: chat between the community’s residents. Chat
Figure 11. The Vecinly app with sample data: overview, bookings, tasks, accounting and chat.

How notices get there

Anything that happens in a community can trigger a notice. The path is always the same: the API runs the use case and saves the change in the database; only once it is saved does it publish an event to a queue. That way whoever took the action does not wait for anything to be sent, and there is never a notice about something that was not recorded.

A consumer picks up the event separately, works out who in that community should hear about it and through which channel each of them prefers, and delivers it: a push notification through Firebase, an email, or instantly over the live channel if they have the app open. If something fails, the event stays in the queue and is retried.

1 · Something happens 2 · Saved 3 · Published 4 · Dispatched 5 · Delivered App or web an action API use case PostgreSQL the change, saved Queue events event Consumer to whom, and how Firebase push to the phone Email to their inbox Live channel to the open app The event goes out after saving: nobody is ever notified of something that was not recorded.
Figure 12. How a notice gets there. The API saves the change and, once saved, publishes an event to a queue. A consumer picks it up separately, decides whom to notify and through which channel, and delivers it without whoever took the action having to wait.

06

Production

Deploying only from a tag

A wrong deployment affects communities’ real data. That is why production is only deployed from a tagged version, and the tests run against that very version. Rolling back means deploying the previous tag.

Clients Web11 languagesVecinly appiOS · AndroidVecinly Pro appiOS · Android sign-in Cloudflare DNS · CDN · firewall EU cloud Private network HTTPS API · Java serverless containers · scheduled jobs private network only PostgreSQL managed · no public address Managed services KMSmaster key Secretsoutside the code Queuesbackground work Filesdocuments and photos Deployment CI: tests → image → private registry → containers, only from a tagged version Infrastructure as code; the database stays outside it and no change can delete it External services Firebaseidentity and push Stripepayments Holdedlegal invoices Emailtransactional
Figure 13. Vecinly in production. Clients come in through Cloudflare to the API. The API and the database share a private network: the database has no public address and only the API reaches it. Alongside, the managed services of the same cloud; outside, Firebase, Stripe, Holded and email.
WhatHow
Tests~4,680; the integration tests run against a real PostgreSQL in containers, never against a mocked database
Before deployingThe unit tests and the critical integration tests: money, subscriptions, permissions and accounting
Mutation testingOn subscriptions and the domain, with a minimum threshold that cannot be lowered without failing the build
DatabaseOutside the infrastructure as code: no infrastructure change can recreate it or delete it
SchemaValidated against the code at startup; if they do not match, the application does not start

Migrations without downtime

Migrations are always idempotent, and expand and contract go in separate versions: first you add and backfill, and the constraint arrives in the next version, because a failing constraint blocks every later deployment.

07

Conclusions

  • The architecture follows the phase. A monolith well divided on the inside was the right thing for validating the product; those same boundaries are what now let it be split into services when growth calls for it.
  • Isolation without multiplying databases. Four layers and one key per community give real isolation without running hundreds of databases.
  • Charging is an idempotency problem. The payment gateway does not prevent double charges: the unique key, the transaction and the sweeps do.
  • Fail closed. No key, no start; no rule, denied; no healthy subscription, back to the free plan.
Write to me about this project →