Notes from building FarmNomad - an AgriTech platform where farmers, customers, drivers, food banks, and admins all share one system.
The interesting constraint in my dissertation project wasn't any single feature. It was that the platform had five different kinds of user, and each one effectively wanted the system to be a different product.
A farmer wants an inventory tool. A customer wants a shop. A driver wants a task list. A food bank (an "organisation" in the code) wants a feed of claimable surplus. An admin wants oversight of all of it. Five mental models, one backend.
This is a writeup of the design decisions that came out of that, with real code from the project.
The domain, briefly
FarmNomad connects rural UK farmers to buyers, delivery drivers, and food banks. A large share of farm-level surplus in the UK goes to waste not for lack of demand, but for lack of a system that coordinates the parties involved.
So the backend has to handle several flows over the same data:
- Agricultural e-commerce (farmer lists produce, customer buys).
- Surplus (products near expiry or overstocked, surfaced for redistribution).
- Donations (a farmer flags surplus, an organisation claims it, a delivery order is generated).
- Delivery coordination (a driver moves goods, whether a paid order or a donation).
Same actors, same inventory, different rules. The design job is keeping those rules from tangling.
Decision one: bounded contexts, not one big service package
The default instinct with an MVP is a single Spring Boot app with everything in one service package. The thing that pushed me away from it was noticing how little the flows actually share. A farmer's inventory workflow and an organisation's claim workflow have almost nothing in common except the Product entity - different lifecycles, different permissions, different reasons to change.
So the backend is organised into modules along domain boundaries: authentication, catalogue, orders, donations, surplus, and payments. The practical payoff showed up when I added the surplus-detection logic late in the build, it didn't require touching the ordering or donation code at all. The seams were already in the right places.
Decision two: enforce roles at the method, not the UI
With five roles, it's tempting to hide buttons on the frontend and call that access control. It isn't anyone with a token and curl can hit the endpoints directly. Role enforcement has to live on the server.
I use Spring Security's @PreAuthorize on the controller methods, so the rule sits right on the operation it governs:
@PostMapping
@PreAuthorize("hasRole('CUSTOMER')")
public ResponseEntity<OrderResponse> placeOrder(Principal principal,
@RequestBody OrderRequest req) { ... }
@PostMapping
@PreAuthorize("hasRole('FARMER')")
public ResponseEntity<SurplusResponse> createListing(Principal principal,
@RequestBody SurplusRequest req) { ... }
@PostMapping("/claim/{productId}")
@PreAuthorize("hasRole('ORGANISATION')")
public ResponseEntity<Void> claimSurplus(@PathVariable Long productId,
Principal principal) { ... }
@PatchMapping("/{orderId}/status")
@PreAuthorize("hasRole('DRIVER') or hasRole('ADMIN')")
public ResponseEntity<OrderResponse> updateStatus(Principal principal,
@PathVariable Long orderId,
@RequestParam OrderStatus status) { ... }
A nice consequence is testability: you can assert that a CUSTOMER token gets a 403 on a farmer-only endpoint without standing up the whole application.
Where the rule is more than "which role," I push it into the service. For example, a single order should only be visible to the customer who placed it, the driver assigned to it, or an admin so the controller allows any authenticated user in, and the service decides. Crucially, an unauthorised requester is told the order doesn't exist rather than that they're forbidden, so order IDs can't be probed:
boolean isAdmin = requester.getRole() == Role.ADMIN;
boolean isCustomer = order.getCustomer() != null
&& order.getCustomer().getId().equals(requesterId);
boolean isDriver = order.getDriver() != null
&& order.getDriver().getId().equals(requesterId);
if (!isAdmin && !isCustomer && !isDriver) {
throw new BadRequestException("Order not found");
}
Decision three: make the order lifecycle explicit, and guard the transitions
Orders move through states PENDING → ASSIGNED → IN_TRANSIT → DELIVERED and the transitions that should be impossible matter more than the ones that should be allowed.
The one I care about most: a driver should never be assigned to an order that hasn't been paid. Without that guard, a race between the payment webhook and an admin clicking "assign" could put a driver on the road for an order that never actually paid. So the assignment path checks both the state and the payment status before it does anything:
public OrderResponse assignDriver(Long orderId, Long driverId) {
Order o = orderRepo.findById(orderId)
.orElseThrow(() -> new BadRequestException("Order not found"));
if (o.getStatus() != OrderStatus.PENDING ||
o.getPaymentStatus() != PaymentStatus.PAID) {
throw new BadRequestException("Only paid, pending orders can be assigned");
}
User driver = userRepo.findById(driverId)
.orElseThrow(() -> new BadRequestException("Driver not found"));
o.setDriver(driver);
o.setStatus(OrderStatus.ASSIGNED);
o.setUpdatedAt(LocalDateTime.now());
return toDto(orderRepo.save(o));
}
Keeping that rule in the service not in a controller, and certainly not only on the frontend means every path that assigns a driver is protected by the same check.
Decision four: two kinds of surplus, deliberately kept distinct
This one is a modelling choice I think is worth calling out. FarmNomad has surplus from two different sources, and they are not the same thing:
- Auto-detected surplus - products that are near their best-before date or overstocked. This is derived at read time from the product data, not stored as its own rows.
- A donation - a farmer explicitly flags a quantity of a product as surplus to give away; an organisation claims it; a delivery order is generated.
Early on it's tempting to collapse these into one concept. Keeping them separate turned out to be the right call, because they answer different questions. Auto-detected surplus is "what's at risk of going to waste right now?" A donation is "what has a farmer committed to give, and who's claiming it?" The donation flow even has a claim-and-order endpoint that claims the donation and spins up the delivery order in one step, which only makes sense because a donation is a concrete, stateful thing, where auto-detected surplus is a live view.
Decision five: idempotent payment webhooks
Payments go through Stripe Checkout, and Stripe delivers webhook events at least once so the checkout.session.completed handler has to be safe to run more than once for the same session. I key idempotency on the Stripe session ID: each order records the session that created it, and the handler short-circuits if that session was already processed.
String sessionId = minimalSession.getId();
// Stripe delivers at-least-once; the same event can arrive twice.
if (orderRepo.existsByStripeSessionId(sessionId)) {
return ResponseEntity.ok(""); // already handled - exit quietly
}
I wrote this one up in more detail as its own post, because webhook idempotency is one of those things that's invisible in development and bites under real traffic.
The stack
| Layer | Choice |
|---|---|
| Backend | Java, Spring Boot, Spring Security |
| Frontend | React, Next.js, Redux Toolkit Query, TailwindCSS |
| Database | PostgreSQL |
| Payments | Stripe Checkout + Webhooks |
| Infra | Docker, Vercel |
As a solo developer building to a deadline, I leaned on modern tooling throughout the architecture, the modelling decisions, and the trade-offs above are mine; the tooling helped me move faster on everything around them.
FarmNomad is live at farmnomad.vercel.app, and it was built as the practical component of my MSc dissertation at the University of Greater Manchester, which was awarded a Distinction.
If you're building multi-role systems in Spring Boot, or working anywhere near AgriTech, I'd like to compare notes - LinkedIn or GitHub.
Top comments (0)