Lazurite Architecture#
Core Principles#
Separation of Concerns#
- UI layer (presentation) vs Data layer (infrastructure), with clear boundaries
- Feature-first organization inside those layers (each feature owns its domain/data/presentation)
- Reactive data flow using Riverpod providers and Drift streams
Feature Layer Architecture#
All features follow a three-layer architecture for clear separation of concerns:
Domain Layer (domain/)#
Purpose: Business logic and data models independent of framework implementation.
Contains:
- Domain models (plain Dart classes, freezed/json_serializable DTOs)
- Business rules and validation logic
- Feature-specific exceptions
Rules:
- No Flutter imports (package:flutter)
- No infrastructure dependencies (Drift, Dio, providers)
- Pure Dart code that could run in any Dart environment
- Models represent the problem domain, not API schemas or database tables
Infrastructure Layer (infrastructure/)#
Purpose: External integrations and data persistence.
Contains:
- Repository implementations (API clients, database DAOs)
- Network request/response handling
- Drift table definitions and database queries
- Data mapping between domain models and external schemas
- Cache management and synchronization logic
Rules:
- Implements contracts defined by domain (if using abstract repositories)
- Handles API-specific details (cursors, pagination, error codes)
- Manages ownerDid scoping for multi-account isolation
- Returns domain models, not API DTOs or Drift entities directly
Application Layer (application/)#
Purpose: State management and coordination between UI and infrastructure.
Contains:
- Riverpod providers and notifiers
- UI state classes (loading, error, data states)
- Orchestration logic combining multiple repositories
- Application-level business rules (e.g., sync triggers, cache invalidation)
Rules:
- Consumes infrastructure repositories via dependency injection
- Exposes reactive streams or state for UI consumption
- No direct Drift queries (delegate to repositories)
- No UI widgets (widgets belong in presentation/)
Presentation Layer (presentation/)#
Purpose: UI components and user interaction handling.
Contains:
- Screens, pages, and widget trees
- UI-specific state (form controllers, animation controllers)
- Navigation logic
- User input handling and validation
Rules:
- Consumes application providers, never repositories directly
- No business logic beyond UI concerns (visibility, formatting, validation feedback)
- No direct database or network access
Feature Organization Example#
features
└── feeds
├── domain # Domain models
│ ├── feed.dart
│ └── feed_post.dart
├── infrastructure # API + caches
│ ├── feed_repository.dart
│ └── feed_content_repository.dart
├── application # Riverpod state
│ ├── feed_notifier.dart
│ └── feed_sync_controller.dart
└── presentation # UI/Widgets
├── feed_screen.dart
└── feed_post_card.dart
Migration Strategy#
Features missing layers should be refactored incrementally:
- Extract domain models from presentation or infrastructure
- Move API/database logic into repositories
- Create application notifiers to coordinate infrastructure
- Update presentation to consume application providers only
Cross-Cutting Infrastructure#
Some infrastructure components serve multiple features and live in lib/src/infrastructure/:
- Auth (
infrastructure/auth/) - OAuth, session management, token refresh - Network (
infrastructure/network/) - Dio clients, XRPC, endpoint routing - Database (
infrastructure/db/) - Drift setup, shared DAOs, migrations - Identity (
infrastructure/identity/) - DID resolution, handle verification
Features consume these via dependency injection through application providers.
ATProto Best Practices#
- Cursor-based pagination everywhere (avoid OFFSET paging for feeds)
- DID + at:// URIs internally; handles are user-facing only
- Service proxying via
atproto-proxyheader for DMs and specialized services
Data Persistence#
- Drift for relational data with reactive queries
- Secure storage (flutter_secure_storage) for tokens/keys, never in Drift
- Offline-first with optimistic updates and sync queues
Multi-Account Data Isolation#
All user-scoped data is keyed by ownerDid to prevent data bleeding between accounts:
Scoped Tables:
SavedFeeds- User's saved feed generatorsFeedContentItems- Cached posts from feedsFeedCursors- Pagination cursors per feedNotifications- User notificationsBlueskyPreferences- Content moderation settingsDmConvos/DmMessages- Direct message conversations
Implementation Pattern:
DAOs accept ownerDid as a required parameter and filter all queries by it.
Stream<List<T>> watchData(String ownerDid) =>
(select(table)..where((t) => t.ownerDid.equals(ownerDid))).watch();
Notifiers resolve ownerDid from the current auth state, defaulting to 'anonymous'
for unauthenticated users viewing public content (e.g., Discover feed).
final ownerDid = (authState is AuthStateAuthenticated)
? authState.session.did
: 'anonymous';
Logout Behavior:
User data is NOT wiped on logout. Instead, ownerDid filtering ensures the next
logged-in user only sees their own data. This allows fast account switching while
maintaining isolation.
Feed Architecture#
The feed system manages both feed metadata and content through two coordinated repositories.
Feed Metadata (FeedRepository)#
Purpose: Manages information about feed generators (algorithms/sources).
Location: lib/src/features/feeds/infrastructure/feed_repository.dart
Responsibilities:
- Syncing saved feeds from user preferences (
app.bsky.actor.getPreferences) - Caching feed metadata (displayName, avatar, creator, likeCount)
- Managing pinned feeds for the feed selector UI
- Discovering trending feeds
- Handling offline-first optimistic updates with preference sync queue
Data Model: SavedFeeds table
- uri, displayName, description, avatar, creatorDid, likeCount, sortOrder, isPinned, lastSynced
Feed URI Constants:
kHomeFeedUri = 'home'- Following feedkDiscoverFeedUri- Discover feed generator URIkForYouFeedUri- For You feed generator URI
Feed Content (FeedContentRepository)#
Purpose: Manages cached post content from feeds.
Location: lib/src/features/feeds/infrastructure/feed_content_repository.dart
Responsibilities:
- Fetching feed content (
app.bsky.feed.getTimeline,app.bsky.feed.getFeed) - Caching posts, profiles, and feed content items
- Managing cursors for pagination
- Providing reactive streams for UI updates
Data Models:
Poststable - post content and engagement metricsProfilestable - author profile dataFeedContentItemstable - feed-to-post relationships with sortKey and reason (repost info)FeedCursorstable - pagination cursors per feed
Data Flow:
FeedContentRepository.fetchAndCacheFeed()
↓
FeedContentDao.insertFeedContentBatch()
↓
Drift: Posts + Profiles + FeedContentItems
↓
FeedContentDao.watchFeedContent() → Stream<List<FeedPost>>
↓
FeedContentNotifier → UI
FeedPost Data Structure#
Simplified view combining data from multiple tables:
class FeedPost {
final Post post; // Post content
final Profile author; // Author profile
final String? reason; // Feed-specific metadata (e.g., repost info)
}
The reason field contains JSON describing why the post appears in the feed
(e.g., "Reposted by @user").
Network Architecture#
Host Routing#
Two Dio instances:
dioPublic- baseUrl:https://public.api.bsky.app(unauthenticated calls)dioPds- baseUrl: user's PDS URL (authenticated calls with automatic proxying)
Endpoint Registry: Never "guess" where to send a call at runtime; encode routing in an endpoint map.
Read-After-Write: PDS can patch AppView reads for consistency after writes.
Service Proxying (DMs)#
Chat requests use the atproto-proxy header:
- Header:
atproto-proxy: did:web:api.bsky.chat#bsky_chat - Requests go to user's PDS and are proxied to the chat service
- Must use PDS proxy; direct calls to public.api.bsky.app will fail
Authentication#
OAuth Implementation#
Requirements:
- DPoP (Demonstrating Proof-of-Possession)
- PAR (Pushed Authorization Request) in initial auth request
- Loopback server for callback handling
Token Refresh Strategy#
- Reactive Refresh (401): When a request returns 401 Unauthorized, attempt token refresh and retry
- Proactive Refresh: When token is within 5 minutes of expiration, refresh before the request
- Session Invalidation (400): When server returns
InvalidTokenorExpiredToken, clear session entirely
Token Lifecycle:
[Created] ──────────> [Near Expiration] ──────> [Expired]
(5 min before)
↓ ↓ ↓
Normal Proactive 401 Retry
Requests Refresh or Invalidate
Cache Management#
Cache Invalidation Triggers#
- Session Logout: Clears session storage, sets AuthState.unauthenticated
- Session Invalidation (InvalidToken): Clears all cached content + logout
- Stale Feed Cleanup:
FeedContentCleanupControllerremoves items not updated in 7 days
Application Lifecycle#
Managed via AppLifecycle provider and direct WidgetsBindingObserver where critical
responsiveness is needed (e.g., autosave).
Lifecycle State Flow#
graph TD
A[App Launched] --> B[Resumed/Foreground]
B -->|User minimizes/switches| C[Inactive]
C -->|OS pauses execution| D[Paused/Background]
D -->|User returns| B
D -->|OS kills app| E[Detached/Terminated]
subgraph "On Resume"
B -.-> F[Feed Sync]
B -.-> G[Preference Sync]
end
subgraph "On Pause/Inactive"
C -.-> H[Composer Autosave]
D -.-> H
end
Implementation Strategy#
- Global Observers:
AppLifecycleprovider (Riverpod) for reactive state management.- Feed Sync:
FeedSyncControllerlistens forAppLifecycleState.resumedto triggerfeedRepository.syncOnResume(), ensuring content is fresh when the user returns. - Preference Sync:
PreferenceSyncControllerfetches remote preferences and processes the background sync queue onresumed.
- Feed Sync:
- Feature-Specific Implementation:
- Composer Autosave: Uses
WidgetsBindingObserverdirectly inComposerScreenstate to triggerforceSaveimmediately onpausedorinactivesignals. This bypasses the slight delay of provider updates to ensure drafts are saved before the OS can kill the process.
- Composer Autosave: Uses
Feature Implementation Patterns#
Draft Publishing Pipeline#
- If draft has media files not yet uploaded →
uploadBlob() - Construct
app.bsky.feed.postrecord with blob references createRecord(collection: app.bsky.feed.post)- On success: mark draft as published (or delete)
Important: Blobs are "temporary" until referenced by a record (time window constraint).
Smart Folders (Future)#
MVP Rule Types:
- Author allow/deny (DID list)
- Keyword include/exclude (post text)
- Has media / has link
- Replies only / exclude replies
- Bookmarked only
- "Unread only" (local read-state)
Execution Strategy:
- Start non-materialized:
SELECT posts WHERE <rules> ORDER BY indexedAt DESC - Upgrade to materialized if needed: On cache insert, evaluate rules and populate folder items
Testing Strategy#
- Unit tests for business logic
- Widget tests for UI components
- Integration tests for repository/DAO interactions
- Target: >95% code coverage