A deep dive into designing SmartPark, a real-time IoT parking management system, using Hexagonal Architecture (Ports & Adapters) instead of microservices. Architecture comparison, IoT stack choices, and why frameworks like NestJS make hexagonal natural.
The Problem: 72 Hours to Detect a Dead Sensor
Imagine a city with 500 parking sensors embedded in the ground. One of them dies. Nobody notices for 72 hours. Meanwhile, the dashboard shows a spot as "occupied" that's been empty for 3 days, agents are dispatched to phantom problems, and citizens can't find parking.
This is the reality of most municipal parking systems today. SmartPark is an independent application built to fix this — real-time occupancy tracking, automatic anomaly detection, and field agent dispatching, all powered by IoT sensors and designed with Hexagonal Architecture.
But the interesting part isn't what it does — it's how it's architected, and why we chose hexagonal over microservices.
What is SmartPark?
SmartPark is a standalone smart parking application for a Smart City platform. It's not a microservice in a larger system — it's an independent, self-contained app with its own database, its own deployment, and its own lifecycle.
Core features:
- Real-time occupancy visualization (free / occupied / out-of-service)
- IoT sensor health monitoring (battery, signal, anomaly detection)
- Automatic alerts with agent dispatching (GPS-optimized)
- Citizen-facing mobile app for availability
- Predictive analytics on historical data
The IoT stack:
- 500 magnetic ground sensors (LoRaWAN) for on-street parking
- Ultrasonic ceiling sensors for covered garages
- MQTT protocol (EMQX broker) for real-time data ingestion
- LoRaWAN gateways aggregating sensor data
The Architecture Decision: Why Not Microservices?
This is where most teams go wrong. You see "IoT" + "real-time" + "multiple user types" and immediately think microservices. We compared 5 architectural patterns:
| Criteria | Layered | Clean | Onion | Hexagonal | Microservices |
|---|---|---|---|---|---|
| Domain isolation | Weak | Strong | Strong | Very strong | Variable |
| Testability | Medium | Good | Good | Excellent | Good per service, hard integration |
| Tech swap | Hard | Easy | Easy | Very easy | Per service |
| Complexity | Low | Medium | Medium | Medium | High (K8s, networking, observability) |
| Fit for standalone app | Yes but coupled | Yes | Yes | Ideal | No — designed for distributed systems |
| IoT real-time fit | Possible | Possible | Possible | Ideal (MQTT = adapter on a port) | Yes but network overhead |
| Author | Classic (90s) | R.C. Martin (2012) | J. Palermo (2008) | A. Cockburn (2005) | Fowler, Newman |
Verdict: Hexagonal wins for SmartPark.
Why? Because SmartPark is a single application, not a distributed system. We don't need Kubernetes, service mesh, eventual consistency, or inter-service communication. We need clean domain isolation with swappable technology adapters — which is exactly what Alistair Cockburn designed Ports & Adapters for.
Clean Architecture and Onion are close cousins, but hexagonal is more concrete in its terminology: "port" and "adapter" map naturally to IoT concepts (an MQTT listener is literally an adapter plugged into a sensor port).
"But Can You Really Do Hexagonal With a Framework?"
This is the question I get the most. And it's a fair one.
Most frameworks are opinionated — they control your code structure. With Express, Laravel, or Django, the framework calls the shots: routes → controllers → models → database. Your business logic ends up scattered across framework constructs, and swapping your database means rewriting everything.
Hexagonal says the opposite: the domain knows nothing about the framework. The framework is just one adapter among many.
So how do you reconcile these two ideas?
The answer: Dependency Injection
Hexagonal architecture needs one key mechanism: the ability to say "when someone asks for this interface (port), give them this implementation (adapter)" — without the domain knowing which implementation it gets.
Some frameworks make this natural:
// NestJS — Hexagonal-friendly by design
// 1. Define the port (interface)
export interface ParkingPlaceRepository {
findByZone(zoneId: string): Promise<ParkingPlace[]>;
updateStatus(placeId: string, status: PlaceStatus): Promise<void>;
findNearestAvailable(lat: number, lng: number): Promise<ParkingPlace[]>;
}
// 2. Domain service uses the port (no framework imports!)
export class ParkingService {
constructor(
private readonly parkingRepo: ParkingPlaceRepository,
private readonly geoPort: GeoSpatialPort,
) {}
async getOccupancy(zoneId: string) {
const places = await this.parkingRepo.findByZone(zoneId);
return places.map(p => ({
id: p.id,
status: p.status,
zone: p.zone,
}));
}
}
// 3. Adapter implements the port
@Injectable()
export class PostgresParkingRepository implements ParkingPlaceRepository {
constructor(@InjectRepository(ParkingPlaceEntity) private repo: Repository<ParkingPlaceEntity>) {}
async findByZone(zoneId: string): Promise<ParkingPlace[]> {
// PostgreSQL + PostGIS query here
}
}
// 4. NestJS DI wires it up
@Module({
providers: [
ParkingService,
{ provide: 'ParkingPlaceRepository', useClass: PostgresParkingRepository },
{ provide: 'GeoSpatialPort', useClass: PostGisGeoAdapter },
],
})
export class ParkingModule {}
The key insight: the domain/ folder imports zero NestJS modules. No @Injectable(), no @Controller(), no ORM decorators. Pure TypeScript. The framework lives entirely in infrastructure/.
Framework compatibility matrix
| Framework | DI built-in | Hexagonal-friendly | Notes |
|---|---|---|---|
| NestJS | Yes (native) | ✅ Excellent | Modules, providers, DI — designed for this |
| Spring Boot | Yes (native) | ✅ Excellent |
@Bean, @Autowired, interfaces |
| .NET / ASP.NET Core | Yes (native) | ✅ Excellent |
IServiceCollection, interfaces |
| FastAPI | Partial (Depends) |
⚠️ Possible with discipline | No true DI container, manual wiring |
| Express.js | No | ⚠️ Hard — build your own DI | Use tsyringe or inversify
|
| Django | No | ❌ Painful | ORM is deeply coupled to models |
| Laravel | Yes (Service Container) | ⚠️ Possible but unusual | Community defaults to Active Record |
The SmartPark Hexagonal Architecture
Here's the full architecture diagram:
┌─────────────────────────────────────┐
│ DRIVING ADAPTERS (Primary) │
│ ┌──────────┐ ┌────────┐ ┌────────┐ │
│ │ REST API │ │MQTT Sub│ │ WS │ │
│ │(NestJS) │ │(IoT) │ │(Dash) │ │
│ └────┬─────┘ └───┬────┘ └───┬────┘ │
└───────┼───────────┼─────────┼───────┘
▼ ▼ ▼
┌─────────────────────────────────────┐
│ INBOUND PORTS │
│ «interface» ParkingUseCase │
│ «interface» SensorUseCase │
│ «interface» AlertUseCase │
├─────────────────────────────────────┤
│ DOMAIN CORE │
│ │
│ Entities: │
│ ParkingPlace · Zone · Sensor │
│ Alert · Intervention · Agent │
│ │
│ Business Rules: │
│ • Free if sensor = 0 for > 30s │
│ • Anomaly if silence > 5min │
│ • Escalate if alert > 30min │
├─────────────────────────────────────┤
│ OUTBOUND PORTS │
│ «interface» ParkingPlaceRepository │
│ «interface» SensorRepository │
│ «interface» NotificationPort │
│ «interface» GeoSpatialPort │
└───────────────┬─────────────────────┘
▼
┌─────────────────────────────────────┐
│ DRIVEN ADAPTERS (Secondary) │
│ ┌──────────┐ ┌────────┐ ┌────────┐ │
│ │PostgreSQL│ │MQTT Pub│ │ Email │ │
│ │+ PostGIS │ │(EMQX) │ │ SMS │ │
│ └──────────┘ └────────┘ └────────┘ │
│ ┌──────────┐ ┌────────┐ │
│ │Timescale │ │ Redis │ │
│ │DB │ │ Cache │ │
│ └──────────┘ └────────┘ │
└─────────────────────────────────────┘
Project structure
smartpark/
├── src/
│ ├── domain/ ← Core (ZERO dependencies)
│ │ ├── entities/ (ParkingPlace, Zone, Sensor, Alert)
│ │ ├── value-objects/ (PlaceStatus, GeoCoordinate, SensorTrame)
│ │ ├── errors/ (PlaceNotFoundError, SensorTimeoutError)
│ │ └── rules/ (OccupancyRule, AnomalyDetectionRule)
│ │
│ ├── application/ ← Ports + Use Cases
│ │ ├── ports/
│ │ │ ├── in/ (ParkingUseCase, SensorUseCase, AlertUseCase)
│ │ │ └── out/ (ParkingPlaceRepo, SensorRepo, NotificationPort)
│ │ └── services/ (ParkingService, SensorService, AlertService)
│ │
│ ├── infrastructure/ ← Adapters (concrete implementations)
│ │ ├── persistence/ (PostgresParkingRepo, TimescaleDBSensorRepo)
│ │ ├── messaging/ (MqttSensorAdapter, MqttPublishAdapter)
│ │ ├── notification/ (EmailAdapter, SmsAdapter, PushAdapter)
│ │ ├── cache/ (RedisCacheAdapter)
│ │ └── http/ (ParkController, AlertController)
│ │
│ └── config/ (DI container, app config)
│
├── test/
│ ├── unit/ ← Domain tests (fast, no infra)
│ ├── integration/ ← Adapter tests (with DB/MQTT)
│ └── e2e/ ← Full API tests
│
├── docker-compose.yml
└── .gitlab-ci.yml
The golden rule: if you import anything from infrastructure/ inside domain/, you've broken the architecture.
IoT Stack: Why LoRaWAN + MQTT + EMQX
Choosing the right IoT communication stack is critical. Here's the comparison that drove our decisions:
Network protocol
| LoRaWAN | Sigfox | NB-IoT | Zigbee | Wi-Fi | |
|---|---|---|---|---|---|
| Range | 2-5 km urban | 10 km | 1-10 km | 10-100 m | 50-100 m |
| Battery life | 5-10 years | 5-10 years | 2-5 years | Good | Poor |
| Cost/sensor | €15-30 | €10-25 | €20-40 | €5-15 | €10-30 |
| Parking fit | ✅ Ideal | ⚠️ Possible | ⚠️ Possible | ❌ Range | ❌ Battery |
Winner: LoRaWAN. 5-10 year battery life means no electrical wiring in the pavement. 2-3 gateways cover a city center. Mature ecosystem of compatible ground sensors (Nedap, Urbiotica, Bosch, PNI).
Application protocol
| MQTT | HTTP/REST | CoAP | AMQP | |
|---|---|---|---|---|
| Model | Pub/Sub | Request/Response | Request/Response (UDP) | Queue/Exchange |
| Overhead | 2 bytes min | Heavy (headers) | Low | Medium |
| Built for IoT | Yes | No | Yes but less mature | No |
Winner: MQTT with EMQX broker (open source, native clustering, MQTT 5.0, millions of simultaneous connections).
The Anomaly Detection Flow
Here's how all the pieces work together when a sensor goes silent:
Sensor (ground) SmartPark App Agent (mobile)
│ │ │
│ MQTT trame │ │
│ every 30s │ │
├───────────────────────►│ │
│ │ │
│ ┌──────────────▼──────────────┐ │
│ silence│ ProcessTrameUseCase │ │
│ > 5min │ 1. Validate trame │ │
│ │ 2. Update place status │ │
× │ 3. Check anomaly rules: │ │
(dead) │ - battery < 15%? ❌ │ │
│ - aberrant value? ❌ │ │
│ - silence > 5min? ✅ │ │
└──────────────┬──────────────┘ │
│ │
┌──────────────▼──────────────┐ │
│ CreateAlertUseCase │ │
│ 1. Create alert │ │
│ 2. Find nearest agent (GPS) │ │
│ 3. Create intervention │────────────────►
│ 4. Send push + SMS │ "Sensor B42 │
│ 5. Start 30min escalation │ anomaly — │
└─────────────────────────────┘ Zone Centre" │
Total time from sensor death to agent notification: < 5 minutes (down from 72 hours).
Tech Stack Summary
| Component | Choice | Why |
|---|---|---|
| Backend | NestJS (TypeScript) | Native DI for hexagonal, non-blocking I/O for real-time |
| IoT module | FastAPI (Python) | ML libraries (pandas, scikit-learn) for predictive analytics |
| Frontend | React 18 + TypeScript | WebSocket real-time, mature ecosystem |
| Mobile | React Native | Code sharing with React, offline SQLite, native GPS/camera |
| Database | PostgreSQL 16 + PostGIS | Geospatial queries ("nearest free spot"), JSONB, replication |
| Time-series | TimescaleDB | PostgreSQL extension, compression, temporal queries |
| Cache | Redis 7 | Sub-ms status reads, pub/sub for dashboard updates |
| MQTT Broker | EMQX | MQTT 5.0, clustering, open source |
| Deployment | Docker Compose | Standalone app — no need for Kubernetes |
| CI/CD | GitLab CI | lint → test → SAST → Docker build → deploy |
| Monitoring | Prometheus + Grafana | Real-time metrics, custom dashboards |
Market Context
For those wondering if smart parking is a real market: it's projected to grow from ~$6-10B in 2024 to $14-53B by 2033 depending on the source (CAGR 10-23%). Key players include Urbiotica, Nedap, Bosch, SKIDATA, Flowbird, and EasyPark. The tech is mature, the demand is real, and municipalities worldwide are actively investing.
Key Takeaways
Not everything needs microservices. SmartPark is a standalone app. Hexagonal gives you all the benefits of domain isolation without the operational complexity of distributed systems.
Hexagonal + NestJS is a natural fit. NestJS's dependency injection maps directly to the ports & adapters pattern. Your
domain/folder stays framework-free.IoT maps beautifully to hexagonal. An MQTT listener is a driving adapter. A PostgreSQL repository is a driven adapter. The domain just processes trames and applies business rules.
The framework is an adapter, not the architecture. If your domain imports your framework, you've coupled yourself. The whole point is that you can swap PostgreSQL for MongoDB, or MQTT for Kafka, by changing an adapter — not your business logic.
Start with the domain, not the framework. Write your entities and business rules in pure TypeScript first. Then wire up the adapters. This is the development sequence Cockburn himself recommends.
Resources
- Alistair Cockburn — Hexagonal Architecture (original paper)
- Netflix Tech Blog — Ready for Changes with Hexagonal Architecture
- AWS — Hexagonal Architecture Pattern
- Cockburn & Garrido — Hexagonal Architecture Explained (book, 2024)
- LoRa Alliance — LoRaWAN Specification
- EMQX — Open Source MQTT Broker
- EMQX — Open Source MQTT Broker
- Grand View Research — Smart Parking Market
- hackernoon — The Smart Parking Market in 2026
If you're building an IoT application and debating between microservices and a simpler architecture, I'd love to hear your experience. Drop a comment below!
Top comments (0)