Architectural Overview
We have designed our architecture based on the principles of Clean Architecture [5] and Onion Architecture [8]. The application is divided into the following layers: Core, Use Case, Application, and Infrastructure/UI. Dependencies only flow inward via dependency injection.
According to the defined architecture we have chosen the following package structure for the backend repository (public transit service):
Since we have adopted an onion architecture, all dependencies point inward. The outer layers interact with abstractions defined in the inner layers or implement interfaces provided by the inner layers. Each layer exposes a set of public interfaces, serving as its outward-facing API. This architecture promotes loose coupling because the implementation of any layer can be changed without requiring modifications to the outer layers.
For example, the service layer provides the PublicTransitService
interface along with relevant parameter and return types, such as ConnectionQueryConfig
and Connection
. The actual implementation of this service, GtfsRaptorService
, is abstracted away from the application layer, making it easily replaceable. In the application layer, PublicTransitSpringService
also implements the PublicTransitService
interface and uses the delegate pattern to forward requests from the REST controllers to the service. This encapsulates all the Spring [12] components, configurations, and dependencies within the outermost app layer, keeping them isolated from the service and core logic.
Similarly, the GtfsScheduleRepository
interface in the service layer is implemented in the application layer by classes such as GtfsScheduleFile
and GtfsScheduleUrl
. For the service itself, it is not relevant how the GTFS schedule is provided, as it only expects a concrete implementation of the repository interface from the instantiator, which is responsible for providing the GTFS schedule.
This concept is applied consistently across the project, with a few deliberate exceptions. One notable drawback of this architecture is that each layer defines its own representation of the same real-world entities, leading to frequent type mapping as requests and responses traverse the layers. A good example is the entity of a transit stop, which is abstracted differently in various layers. In the core layer, we maintain two distinct abstractions: one for GTFS stops and another for stops used by the RAPTOR algorithm. This separation exists because the concept of a transit stop differs based on the context of the layer. For instance, in the GTFS layer, technical details such as possible transfers are prioritized, while in the service layer, information from the passenger’s perspective, such as the stop’s full name and available routes, takes precedence. In these cases, maintaining separate abstractions for transit stops is justified due to the distinct roles they play across layers.
However, for more stable concepts like locations and coordinates, a unified representation suffices across all layers. Instead of duplicating these entities within each layer, we extracted them into a cross-cutting utils.spatial
module. These data types are shared across layers without any need for mapping, improving both performance and maintainability.