Lessons from Building Triton
I've been building Triton, a native macOS client for omg.lol. For those unfamiliar, omg.lol is an amazing web service1 that provides a collection of fun, useful tools like status updates, permanent URLs, web pages, weblogs, pastebin, and more, all tied to a personal address.
Triton brings these to the desktop with a native macOS experience. Users can post status updates, manage their PURLs (permanent URLs), edit their web pages and now pages, write weblog entries, upload pictures to some.pics, and manage code snippets in the pastebin; all from a single, unified application. The app supports multiple omg.lol addresses, letting users switch between them.

Triton
Beyond basic functionality, Triton includes quality-of-life features like local muting (hide specific addresses or keywords from your statuslog timeline), sharing through native macOS share sheets and statuslog posts, context menus for quickly copying URLs in various formats (plain URLs, Markdown links, or Markdown code blocks), and Spotlight and Shortcuts integration.
Triton isn't available for download yet, but I'm planning to release it soon. When it launches, it will be open source and available on GitHub as well, allowing others to read its code, contribute to, or adapt it for their own needs.
Once Triton is released, I'll write a proper introduction covering the app from a user perspective; its features and workflows. For now, though, I want to focus on how the application was built and the architectural decisions behind it. Like the source code itself, these decisions are documented as Architecture Decision Records (ADRs) and will be available on GitHub, providing context for anyone interested in understanding or adapting the architecture.
Building a macOS application from scratch, or any application to be honest, presents an opportunity to define architectural patterns that will serve the project for years to come. Rather than starting with a monolithic structure and refactoring later, I chose to implement a modular, feature-isolated, layered architecture from day one using Swift Package Manager.
Modularity with Swift Package Manager
The architecture centers on isolated packages within a Packages/ directory. Each package represents either a feature domain or an infrastructure concern, creating boundaries that are enforced by the compiler.
Packages/
├── Status/ # Feature: Status updates
├── PURLs/ # Feature: URL management
├── Account/ # Feature: User account
├── ... # Feature: ...
├── OMGAPI/ # Infrastructure: API client
├── DesignSystem/ # Infrastructure: UI components
├── SessionService/ # Infrastructure: Session management
├── ... # Infrastructure: ...
└── FoundationExtensions/ # Utilities
Swift Package Manager enforces dependency relationships at compile time. A feature package cannot accidentally import another feature's internal implementation. It's impossible to violate this without explicitly modifying Package.swift files.
Currently, the application has 19 packages in the Packages/ directory, not counting external dependencies like MicroClient and MicroContainer (which I created as well). These 19 packages contain more than 50 modules (targets), with each feature typically splitting into targets representing different architectural layers. This might seem excessive at first, but the granular structure provides precise control over dependencies and enables isolation between concerns.
The impact is felt immediately. Xcode rebuilds only changed packages and their dependents, improving incremental build times. More importantly, the architecture accommodates growth without major changes.
One-Way Dependencies
Within this modular foundation, I established strict architectural layers with enforced one-way dependency flow:
Views
|
View Models
|
Repositories
|
Network & Persistence Services
|
Shared Services (OMGAPI, SessionService)
|
Foundation Modules (Utilities)
These layers are enforced through Swift Package Manager's dependency system. Feature packages implement their layers as separate targets, while infrastructure and utility packages occupy the lower layers. The compiler prevents any upward dependencies.
Views and View Models depend on Repository protocols, never directly on Network Service or Persistence Service. This isolation enables testing at each layer independently. A View Model test uses a mock repository without touching network or persistence code. A Repository test uses mock services without real API calls or database operations.
The Repository layer coordinates between Network Service and Persistence Service, implementing domain logic and caching strategies. This is where the application decides when to fetch from the network versus reading from local storage, how to handle conflicts, and when to invalidate caches.
Vertical Slices
Each feature package implements a complete vertical slice through all architectural layers:
Status/
├── Package.swift
├── Sources/
│ ├── Status/ # Main UI and integration
│ │ ├── Views/
│ │ ├── Scenes/
│ │ └── Factories/
│ ├── StatusRepository/ # Data coordination
│ ├── StatusNetworkService/ # API communication
│ └── StatusPersistenceService/ # Local storage
└── Tests/
Features expose themselves through a standardized App Factory pattern:
public final class StatusAppFactory {
private let environment: StatusEnvironment
public init(
sessionService: any SessionServiceProtocol,
authSessionService: any AuthSessionServiceProtocol,
networkClient: NetworkClientProtocol
) {
environment = .init(
sessionService: sessionService,
authSessionService: authSessionService,
networkClient: networkClient
)
}
@MainActor
@ViewBuilder
public func makeAppView() -> some View {
let viewModel = environment.viewModelFactory
.makeStatusAppViewModel()
StatusApp(viewModel: viewModel)
.environment(\.viewModelFactory, environment.viewModelFactory)
.modelContainer(environment.modelContainer)
}
@MainActor
public func makeScene() -> some Scene {
ComposeStatusScene(environment: environment)
}
}
Everything else, Views, View Models, Repositories, Services, remains internal to the package. Features cannot access each other's internals, enforcing true isolation.
I built Triton alone, but on a team, this structure promotes shared ownership. Any engineer can work on any feature without needing to understand the entire codebase. The boundaries are clear, the contracts are explicit, and the architecture naturally guides correct implementation.
Protocol-First Boundaries
At layer boundaries, I use protocols to define contracts. Each repository and service has a public protocol marked Sendable for concurrency safety:
public protocol PURLsRepositoryProtocol: Sendable {
var purlsContainer: ModelContainer { get }
func fetchPURLs() async throws
func addPURL(address: String, name: String, url: String) async throws
func deletePURL(address: String, name: String) async throws
}
Implementations are actor types, providing automatic serialization and thread safety:
actor PURLsRepository: PURLsRepositoryProtocol {
private let networkService: any PURLsNetworkServiceProtocol
private let persistenceService: any PURLsPersistenceServiceProtocol
// Implementation details...
}
One feature I like in Xcode is that I only need to document protocols. When opening the Quick Help to read a method's documentation, Xcode displays the protocol's documentation if that method comes from a protocol the type implements. This means I document public protocols, the contracts defining what each layer provides, while implementation details remain undocumented and internal, reducing maintenance burden when internals change. When the code is released on GitHub, all public protocols will be fully documented.
Since protocols are public but implementations are internal, concrete types are built and made available to other layers through factory methods. Each service and repository module includes factories that create and return instances:
func makeRepository() -> any PURLsRepositoryProtocol {
PURLsRepository(
networkService: makeNetworkService(),
persistenceService: makePersistenceService(),
authSessionService: authSessionService,
sessionService: sessionService
)
}
This pattern ensures consumers depend on protocol contracts while factories handle the creation of concrete implementations, maintaining encapsulation throughout the architecture.
Modern Swift Throughout
The architecture leverages modern Swift features at every layer:
Concurrency: Actors for services and repositories ensure thread safety without manual locks. All async operations use async/await.
Observation: View Models use
@Observableinstead ofObservableObject, eliminating boilerplate and improving performance through fine-grained observation.Swift Data: All persistence uses Swift Data with
@Modeltypes, avoiding Core Data's complexity for straightforward data models.Type Safety: Type safety everywhere. E.g., in the network layer,
NetworkRequest<Request, Response>provides compile-time safety for API calls. DTOs in the OMGAPI package define API contracts explicitly.
Data Flow: DTOs to Domain Models
Data flows through distinct representations at each layer:
Network API
↓
DTOs (OMGAPI package) ← Codable, API-shaped
↓
Repository ← Maps to domain/persistence models
↓
Domain Models (@Model or structs)
↓
ViewModels / Views
The OMGAPI package contains all request and response DTOs, matching the API structure exactly. Network Services return these DTOs unchanged. Repositories map DTOs to domain models or persistence models, applying business logic during transformation.
This decoupling means API changes don't ripple through all layers. The repository acts as an anti-corruption layer, translating between the external API contract and internal domain representation.
Streaming-Based Caching
Repositories coordinate data synchronization through AsyncStream patterns:
actor PURLsRepository: PURLsRepositoryProtocol {
private func startPURLsSync() {
streamTask = Task { [weak self] in
guard let self else { return }
for await purls in networkService.purlsStream() {
let storablePurls = purls.map { purlResponse in
StorablePURL(address: current, purlResponse: purlResponse)
}
try await persistenceService.storePURLs(purls: storablePurls)
}
}
}
}
Network operations emit data through streams. Repositories listen and automatically persist incoming data. Views query Swift Data directly using @Query, receiving updates automatically through SwiftData's observation.
This pattern provides automatic synchronization with minimal manual coordination. Data flows naturally from network through repositories to local storage and UI, with the repository managing the stream lifecycle.
Dependency Injection
Feature integration happens through a lightweight dependency injection container. The main application's TritonEnvironment registers infrastructure and App Factory factories:
struct TritonEnvironment: TritonEnvironmentProtocol {
private let container = DependencyContainer()
var statusAppFactory: StatusAppFactory { container.resolve() }
init() {
container.register(
type: StatusAppFactory.self,
allocation: .static
) { container in
StatusAppFactory(
sessionService: container.resolve(),
authSessionService: container.resolve(),
networkClient: container.resolve()
)
}
}
}
The container supports .static allocation for singletons and passes itself to factory closures for resolving dependencies. Type-safe resolution through container.resolve() catches missing registrations at compile time.
Internally, each feature has its own environment which registers the services and repositories that belong to that feature. The feature's AppFactory receives infrastructure dependencies from the main application's container and uses them to initialize the feature's environment.
It's worth noting that infrastructure services like Session Service, Auth Session Service, and Network Client follow a specific module pattern. Features depend only on protocol-defining interface modules; for example, a feature imports SessionServiceInterface but never SessionService directly. Only the main application links against modules containing concrete implementations. This separation ensures features remain decoupled from infrastructure implementation details, depending solely on contracts.
Pure SwiftUI
The entire UI is SwiftUI. No AppKit or UIKit2 anywhere. A Design System package provides shared components: view modifiers, toolbar items, colors, and spacing constants. Features compose from these building blocks, ensuring consistency across the application.
SwiftUI Previews enable rapid development. I use the Mother Object pattern to create fixtures:
enum StatusViewModelMother {
@MainActor
static func makeStatusViewModel(
statuses: [Status] = StatusMother.makeStatuses(),
isLoading: Bool = false
) -> StatusViewModel {
.init(
statuses: statuses,
isLoading: isLoading,
repository: StatusRepositoryMother.makeRepository()
)
}
}
These Mother Objects serve double duty: SwiftUI Previews during development and test fixtures for unit tests. Ninety percent of views have multiple preview states, catching UI issues immediately.
#Preview("Not Loading") {
StatusView(
viewModel: StatusViewModelMother.makeStatusViewModel()
)
.frame(width: 420)
}
#Preview("Loading") {
StatusView(
viewModel: StatusViewModelMother.makeStatusViewModel(
isLoading: true
)
)
.frame(width: 420)
}
Features Built on This Architecture
The architecture's true test comes from the features it supports. Triton implements a complete suite of omg.lol services, each as an isolated feature package:
Statuslog provides a timeline of status updates with local muting capabilities. Users can mute specific addresses or keywords, useful for avoiding spoilers or filtering unwanted content.
PURLs manages permanent URLs. Users create, edit, and delete PURLs, with context menus for copying URLs in various formats (plain URLs or Markdown links) and sharing via the native share sheet or directly to Statuslog.
Web Page and Now Page features let users view and edit their omg.lol pages directly from the app. Changes publish instantly, eliminating the need to use a web browser for content updates.
Weblog offers full-featured blog management. Users write new posts, edit existing entries, and delete old content. Each entry has a context menu with sharing options and the ability to open posts in a browser.
Pics integrates with some.pics for image management. Users upload new pictures, delete existing ones, and share photos through various formats: Some Pics URLs, direct image URLs, or Markdown image syntax.
Pastebin handles code snippets and text pastes. Users create new pastes with syntax highlighting support, edit existing snippets, and share public pastes through context menus. Private pastes remain accessible only within the app.
The architecture supports multiple omg.lol addresses. Users switch between addresses without friction, with each feature respecting the currently selected address. Session management and authentication happen through the Auth Session infrastructure package.
Consequences and Tradeoffs
This architecture requires upfront planning before writing feature code. Creating a new feature means setting up package structure, defining protocols, and implementing layers sequentially. There's more initial overhead than throwing everything in a single target.
But the benefits justify this cost. The architecture prevents common problems:
Circular dependencies: Impossible due to SPM's acyclic dependency enforcement
Tight coupling: Layer boundaries and feature isolation prevent accidental coupling
Difficult testing: Protocol boundaries enable mock implementations at every layer
Architectural erosion: Compiler enforces the architecture continuously
Parallel development becomes straightforward. As I mentioned above, I built Triton alone, but in a larger codebase with multiple engineers, they can work on different features without conflicts because features don't share code. When features need common functionality, it must be explicitly extracted to infrastructure packages, making shared dependencies visible and intentional.
And the modular structure scales naturally. Adding a new feature follows established patterns. The architecture accommodates growth without major restructuring because the boundaries were established from the beginning.
Conclusion
Building a modular, feature-isolated, layered architecture from day one establishes patterns that serve the project long-term. Swift Package Manager enforces boundaries at compile time. Strict layering with one-way dependencies prevents coupling.
Modern Swift features like actors, async/await, @Observable, and Swift Data work naturally within this structure. The protocol-first approach at boundaries enables testing while keeping implementation details private. Streaming-based caching provides automatic synchronization with minimal coordination code.
The architecture isn't free. It requires initial setup and ongoing discipline. But it prevents common problems before they occur and scales naturally as the codebase grows. For a project intended to last years, this investment pays off.
I'm looking forward to releasing the application. I'm currently reviewing the documentation and the ADRs, and have to prepare, sign, and notarize a build to publish on GitHub. Hopefully soon.
And better yet, omg.lol was created and is maintained by Adam, a genuinely good person who stands for the right causes and created one of the most welcoming communities on the web.↩
The application compiles for iOS and iPadOS as well, but I still have to refactor the UI to look better on these systems.↩