DEV Community

Schat Carino
Schat Carino

Posted on

Building a Smart Parking IoT App

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 {}
Enter fullscreen mode Exit fullscreen mode

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  │            │
         │  └──────────┘ └────────┘            │
         └─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" │
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.

  2. Hexagonal + NestJS is a natural fit. NestJS's dependency injection maps directly to the ports & adapters pattern. Your domain/ folder stays framework-free.

  3. 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.

  4. 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.

  5. 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


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)