Skip to main content

2 posts tagged with "Clean Architecture"

View All Tags

Mastering BLoC for Flutter Apps: Advanced Architecture & What's New in BLoC 9

Published: · 6 min read
Appxiom Team
Mobile App Performance Experts

Flutter applications have evolved rapidly over the past few years, but one thing remains constant - state management can make or break your application architecture. While Flutter offers several state management approaches, Flutter BLoC continues to be the preferred choice for teams building scalable, testable, and enterprise-grade applications.

With BLoC 9, the library introduces meaningful architectural improvements rather than simply adding new APIs. Several legacy patterns have been removed, widget safety has improved, testing has become easier, and the framework now better aligns with modern Flutter development practices.

In this guide, you'll learn:

  • What's new in BLoC 9 State Management
  • How to migrate from BLoC 8
  • Why BlocOverrides is gone
  • How BlocListener Mounted Checks eliminate common navigation crashes
  • Better testing using bloc_test and mocktail
  • Clean Architecture patterns for production Flutter applications

Why Flutter BLoC Still Matters

As Flutter applications grow, state becomes increasingly difficult to manage. Screens communicate with repositories, services, APIs, authentication layers, local storage, and background tasks.

Without a proper architecture, developers often face:

  • Business logic inside widgets
  • Difficult debugging
  • Tight coupling
  • Poor testability
  • Unexpected rebuilds
  • Memory leaks

Flutter BLoC solves these problems by separating:

  • Presentation
  • Business Logic
  • Data Layer

This separation makes applications predictable, maintainable, and easy to scale.

What's New in BLoC 9 State Management

BLoC 9 is less about introducing new features and more about simplifying existing workflows while improving developer experience.

The biggest changes include:

  • Removal of BlocOverrides
  • Direct global configuration using Bloc.observer
  • Built-in mounted checks inside BlocListener
  • Improved testing abstractions
  • Simplified mocking through EmittableStateStreamableSource

Let's explore each of these changes.

Goodbye BlocOverrides

One of the biggest migration changes is the removal of BlocOverrides.

Before (BLoC 8)

Developers typically wrapped their application like this:

void main() {
BlocOverrides.runZoned(
() => runApp(MyApp()),
blocObserver: MyBlocObserver(),
);
}

Although functional, this added unnecessary boilerplate around application startup.

After (BLoC 9)

Configuration is now much simpler.

void main() {
Bloc.observer = MyBlocObserver();

runApp(MyApp());
}

You can also configure a global event transformer directly.

Bloc.transformer = sequential();

Bloc.observer = MyBlocObserver();

runApp(MyApp());

This makes initialization cleaner while removing an entire abstraction layer.

Benefits

  • Less boilerplate
  • Easier onboarding
  • Simpler application startup
  • Cleaner global configuration

Using Bloc.observer Effectively

Bloc.observer remains one of the most powerful debugging tools in Flutter BLoC.

A custom observer can monitor:

  • Events
  • State transitions
  • Errors
  • Bloc creation
  • Bloc disposal

Example:

class MyBlocObserver extends BlocObserver {

@override
void onEvent(
Bloc bloc,
Object? event,
) {
super.onEvent(bloc, event);

debugPrint(event.toString());
}

@override
void onTransition(
Bloc bloc,
Transition transition,
) {
super.onTransition(bloc, transition);

debugPrint(transition.toString());
}

@override
void onError(
BlocBase bloc,
Object error,
StackTrace stackTrace,
) {
super.onError(
bloc,
error,
stackTrace,
);
}
}

This is particularly useful when diagnosing production issues or understanding complex event flows.

BlocListener Mounted Checks

One of the most practical additions in BLoC 9 State Management is automatic mounted checking.

The Problem

Many Flutter applications crashed because asynchronous events completed after a widget had already been disposed.

Typical code looked like:

listener: (context, state) {

if (!context.mounted) return;

Navigator.push(
context,
MaterialPageRoute(
builder: (_) => HomePage(),
),
);
}

Developers frequently forgot this check, leading to:

  • Navigation exceptions
  • Dialog errors
  • SnackBar failures
  • Context-related crashes

BLoC 9 Solution

BlocListener and BlocConsumer now perform mounted checks internally before executing listeners.

Your listener becomes much cleaner:

BlocListener<AuthBloc, AuthState>(
listener: (context, state) {

Navigator.push(
context,
MaterialPageRoute(
builder: (_) => HomePage(),
),
);

},
child: LoginPage(),
)

The framework ensures the widget is still mounted before invoking the callback.

Advantages

  • Fewer crashes
  • Cleaner listener code
  • Less defensive programming
  • Safer navigation

EmittableStateStreamableSource

Testing has also improved significantly.

BLoC 9 introduces the EmittableStateStreamableSource interface.

Although many developers won't interact with it directly, it simplifies:

  • Mocking
  • Fake blocs
  • Stream-based testing
  • Shared abstractions

Instead of relying on multiple internal interfaces, testing tools now work against a more consistent contract.

This makes mocking significantly easier across packages.

Flutter Clean Architecture with BLoC

Flutter BLoC works best when combined with Flutter Clean Architecture.

A recommended project structure looks like:

lib/

├── presentation/
│ ├── pages/
│ ├── widgets/
│ └── bloc/

├── domain/
│ ├── repositories/
│ ├── usecases/
│ └── entities/

├── data/
│ ├── repositories/
│ ├── models/
│ └── datasource/

└── core/

Each layer has a single responsibility.

Presentation handles UI.

Domain contains business rules.

Data communicates with APIs and local storage.

Repository + BLoC Pattern

A common production architecture looks like this:

UI

Bloc

Use Case

Repository

API / Database

The UI never communicates directly with repositories.

Instead:

  • Widgets dispatch events.
  • BLoC processes events.
  • Repository fetches data.
  • BLoC emits new states.
  • UI rebuilds.

This keeps every layer independent.

Managing State Emission Correctly

One common anti-pattern is emitting states after asynchronous operations without considering lifecycle.

Instead of mixing business logic inside widgets:

on<LoginRequested>((event, emit) async {

emit(LoginLoading());

final user = await repository.login();

emit(LoginSuccess(user));

});

Keep business rules inside repositories and use cases while BLoC focuses on state transitions.

Testing with bloc_test

Testing remains one of Flutter BLoC's strongest advantages.

A typical test looks like:

blocTest<LoginBloc, LoginState>(

'emits loading then success',

build: () => LoginBloc(
repository,
),

act: (bloc) {

bloc.add(
LoginRequested(),
);

},

expect: () => [

LoginLoading(),

LoginSuccess(),

],

);

This makes behavior predictable and easy to verify.

Mocking with mocktail

Repositories are usually mocked using mocktail.

class MockRepository
extends Mock
implements UserRepository {}

Then stub responses.

when(
() => repository.login(),
).thenAnswer(
(_) async => user,
);

This isolates business logic from external dependencies.

Best Practices for BLoC 9

When building production Flutter applications:

  • Keep blocs focused on a single responsibility.
  • Use repositories for data access.
  • Keep widgets free from business logic.
  • Configure Bloc.observer globally.
  • Write tests using bloc_test.
  • Mock dependencies using mocktail.
  • Avoid large, monolithic blocs.
  • Prefer immutable state classes.
  • Separate events and states clearly.
  • Keep UI reactive instead of imperative.

Common Migration Checklist

Migrating from BLoC 8 is straightforward.

  • Remove BlocOverrides.runZoned()
  • Configure Bloc.observer directly
  • Configure global transformers directly
  • Update dependencies
  • Remove unnecessary context.mounted checks inside BlocListener
  • Update unit tests if relying on older mocking abstractions

Production Tips

For larger applications:

  • Split feature modules into independent blocs.
  • Keep repositories injectable.
  • Log transitions with Bloc.observer.
  • Test every event path.
  • Avoid creating blocs inside frequently rebuilding widgets.
  • Dispose resources properly.
  • Prefer feature-first architecture over layer-first organization for very large codebases.

Monitoring State Management Issues Beyond Testing

Even with robust architecture and comprehensive tests, some issues only surface under real user conditions. Race conditions, unexpected event sequences, network latency, and device-specific behaviors can lead to state inconsistencies that are difficult to reproduce locally.

To improve observability in production, consider monitoring:

  • Unhandled exceptions during state transitions
  • Navigation failures triggered by asynchronous events
  • Widget rebuild performance
  • API latency affecting state updates
  • Memory usage and app responsiveness
  • User journeys that fail due to state-related issues

Combining well-tested BLoCs with production monitoring helps identify issues that automated tests may not cover, allowing teams to prioritize fixes based on their impact on real users.

Conclusion

BLoC 9 refines an already mature state management library by removing outdated APIs, improving widget safety, and making testing more consistent. The migration from previous versions is relatively simple, with most changes focused on cleaner configuration and better developer ergonomics.

By adopting Flutter BLoC alongside Flutter Clean Architecture, using Bloc.observer for application-wide insights, leveraging the built-in BlocListener Mounted Checks, and writing reliable tests with bloc_test and mocktail, you can build Flutter applications that are easier to maintain, scale, and debug as they grow in complexity.

Mastering SwiftData: Architectural Best Practices for Scalable SwiftUI Apps

Published: · 13 min read
Don Peter
Cofounder and CTO, Appxiom

SwiftData finally gives SwiftUI a first‑class persistence story, but “just make it work” demos don’t answer the questions you hit in production: How do I design a clean SwiftData architecture in SwiftUI? Where should ModelContainer live? How do I do background work safely? How do I test it? This guide distills production‑oriented patterns and SwiftData best practices so your “hello world” doesn’t become a maintenance nightmare. If you’re searching for “swiftdata architecture swiftui,” this post walks you end‑to‑end with code you can ship.

Prerequisites

  • Xcode 15.4+ (Swift 5.9+)
  • iOS 17+ (SwiftData is iOS 17+; some fixes landed in 17.2/17.4, so target the latest when possible)
  • SwiftUI + Swift Concurrency
  • Optional: Observation framework (@Observable) for MVVM

The production problem SwiftData solves (and what it doesn’t)

SwiftData gives you:

  • Declarative models with @Model
  • A simple persistence stack (ModelContainer + ModelContext)
  • Tight SwiftUI integration via @Query and environment(.modelContext)
  • Type‑safe fetches via FetchDescriptor and #Predicate

What it doesn’t solve by itself:

  • Data layer boundaries and testability
  • Background work and thread safety beyond the main actor
  • Separation of concerns for scalable codebases (feature modules, repositories, DI)

The rest of this article shows how to build those missing pieces.

SwiftData architecture in SwiftUI: a production‑ready blueprint

This is the baseline “swiftui data layer architecture” I recommend for most apps:

  • App layer

    • Owns a single ModelContainer configured at startup
    • Injects the container into SwiftUI via .modelContainer(…)
    • Wires up repositories and feature services (via DI)
  • Data layer (SwiftData)

    • @Model types encapsulating persistence schema
    • Repositories that speak domain language and use ModelContext internally
    • Background actors for heavy or long‑running work
  • Domain layer

    • Plain Swift types for business logic and ViewModel state
    • Optional mappers to/from @Model to avoid leaking persistence concerns
  • Presentation layer (SwiftUI)

    • MVVM with @Observable or ObservableObject
    • Simple screens can use @Query for lists
    • Mutations go through ViewModels -> repositories/services

This keeps SwiftUI reactive and simple while making your data layer testable and scalable.

Defining SwiftData models with relationships

We’ll build a small “Projects & Tasks” feature end‑to‑end.

import SwiftData

@Model
final class Project {
var id: UUID
var name: String
var createdAt: Date

@Relationship(deleteRule: .cascade, inverse: \Task.project)
var tasks: [Task]

init(id: UUID = UUID(), name: String, createdAt: Date = .now, tasks: [Task] = []) {
self.id = id
self.name = name
self.createdAt = createdAt
self.tasks = tasks
}
}

@Model
final class Task {
var title: String
var isDone: Bool
var dueDate: Date?

// Inverse is declared on Project.tasks
var project: Project?

init(title: String, isDone: Bool = false, dueDate: Date? = nil, project: Project? = nil) {
self.title = title
self.isDone = isDone
self.dueDate = dueDate
self.project = project
}
}

Notes and best practices:

  • Prefer stable, domain‑meaningful IDs (UUID) alongside SwiftData’s internal identifier (persistentModelID) if you need cross‑layer mapping or external sync.
  • Set delete rules on relationships intentionally. Here we cascade‑delete tasks when a project is deleted.
  • Keep models lean - avoid computed properties with heavy logic; put logic in services or ViewModels.

ModelContainer setup and dependency injection

Create a single ModelContainer for your app and inject it into SwiftUI. This allows @Query and @Environment(.modelContext) to work out of the box.

import SwiftUI
import SwiftData

@main
struct ProjectsApp: App {
var body: some Scene {
WindowGroup {
ProjectListScreen()
}
.modelContainer(for: [Project.self, Task.self])
}
}

If you need custom configuration (e.g., in‑memory for tests, multiple stores), build it manually and pass it into .modelContainer(_:)

@main
struct ProjectsApp: App {
private let container: ModelContainer = {
let config = ModelConfiguration() // customize if needed
return try! ModelContainer(for: [Project.self, Task.self], configurations: config)
}()

var body: some Scene {
WindowGroup {
ProjectListScreen()
}
.modelContainer(container)
}
}

Tip: Keep ModelContainer at the app boundary and inject it into repositories and background actors.

Clean repositories on top of SwiftData (swiftdata clean architecture)

Define domain DTOs to decouple UI/business logic from persistence details.

// Domain-facing DTOs
struct ProjectDTO: Identifiable, Equatable {
let id: UUID
let name: String
let createdAt: Date
}

struct TaskDTO: Identifiable, Equatable {
let id: UUID
let title: String
let isDone: Bool
let dueDate: Date?
let projectId: UUID
}

Repository protocol:

protocol ProjectsRepository {
func allProjects() throws -> [ProjectDTO]
func createProject(name: String) throws -> ProjectDTO
func addTask(to projectId: UUID, title: String, dueDate: Date?) throws -> TaskDTO
func toggleAllTasksOfProject(_ projectId: UUID, isDone: Bool) throws
func deleteProjects(_ ids: [UUID]) throws
}

SwiftData implementation:

import SwiftData

struct SwiftDataProjectsRepository: ProjectsRepository {
private let container: ModelContainer

init(container: ModelContainer) { self.container = container }

// Map functions
private func map(_ p: Project) -> ProjectDTO {
ProjectDTO(id: p.id, name: p.name, createdAt: p.createdAt)
}
private func map(_ t: Task, projectId: UUID) -> TaskDTO {
TaskDTO(id: t.project?.id == projectId ? t.project!.id : (t.project?.id ?? UUID()),
title: t.title,
isDone: t.isDone,
dueDate: t.dueDate,
projectId: t.project?.id ?? projectId)
}

func allProjects() throws -> [ProjectDTO] {
let context = ModelContext(container)
var descriptor = FetchDescriptor<Project>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 200 // production: page if needed
let projects = try context.fetch(descriptor)
return projects.map(map)
}

func createProject(name: String) throws -> ProjectDTO {
let context = ModelContext(container)
let model = Project(name: name)
context.insert(model)
try context.save()
return map(model)
}

func addTask(to projectId: UUID, title: String, dueDate: Date?) throws -> TaskDTO {
let context = ModelContext(container)

let pDesc = FetchDescriptor<Project>(
predicate: #Predicate { $0.id == projectId },
fetchLimit: 1
)
guard let project = try context.fetch(pDesc).first else {
throw NSError(domain: "ProjectsRepository", code: 404, userInfo: [NSLocalizedDescriptionKey: "Project not found"])
}

let task = Task(title: title, dueDate: dueDate, project: project)
context.insert(task)
try context.save()

return TaskDTO(id: task.title.hashValue.uuid, // or better: add UUID on Task model
title: task.title,
isDone: task.isDone,
dueDate: task.dueDate,
projectId: project.id)
}

func toggleAllTasksOfProject(_ projectId: UUID, isDone: Bool) throws {
let context = ModelContext(container)
let tDesc = FetchDescriptor<Task>(
predicate: #Predicate { ($0.project?.id) == projectId } // Example: toggle all tasks in a project
)
let tasks = try context.fetch(tDesc)
for t in tasks { t.isDone = isDone }
try context.save()
}

func deleteProjects(_ ids: [UUID]) throws {
let context = ModelContext(container)
let pDesc = FetchDescriptor<Project>(
predicate: #Predicate { ids.contains($0.id) }
)
for p in try context.fetch(pDesc) {
context.delete(p) // cascades to tasks
}
try context.save()
}
}

// Utility to convert Int hash to UUID for demo purposes only
private extension Int { var uuid: UUID { UUID(uuidString: String(format: "%08X-0000-0000-0000-%012X", self, self)) ?? UUID() } }

Notes:

  • For production, add a real UUID to Task as well; don’t derive one from a hash (shown here to keep the example small).
  • Each repository method creates its own ModelContext (lightweight) and saves explicitly. Avoid sharing ModelContext across threads/actors.

Background work with SwiftData ModelActor concurrency

Heavy imports, sync, and cleanup should not block the main actor. In SwiftData, create a dedicated actor for isolation. Using a custom actor is fine; Apple also provides ModelActor, which streamlines access to a ModelContainer and ModelContext. If you prefer not to rely on macros, a plain actor works well:

actor ProjectsBackgroundWorker {
private let container: ModelContainer

init(container: ModelContainer) { self.container = container }

// Example: Import projects from a CSV on a background actor
func importProjects(fromCSV url: URL) throws -> Int {
let context = ModelContext(container)
context.undoManager = nil // reduce memory for batch work

let data = try Data(contentsOf: url)
let text = String(decoding: data, as: UTF8.self)

var count = 0
for line in text.split(separator: "\n") {
let name = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { continue }
context.insert(Project(name: name))
count += 1

// Save in batches to keep memory low
if count % 100 == 0 {
try context.save()
}
}
try context.save()
return count
}
}

Why this pattern:

  • ModelContext is not thread-safe; creating and using it inside an actor guarantees isolation.
  • Avoid passing @Model instances across actors. If you must reference a specific record, pass its persistentModelID or your own UUID, and re-fetch in the target context.

If you’re comfortable with the SwiftData macro, @ModelActor can synthesize some of this boilerplate. The underlying best practice stays the same: keep context usage confined to a single actor.

MVVM with SwiftUI and SwiftData

You can lean on @Query for simple screens and still keep mutations behind a ViewModel.

import SwiftUI
import SwiftData
import Observation

@Observable
final class ProjectListViewModel {
private let repo: ProjectsRepository

init(repo: ProjectsRepository) { self.repo = repo }

@MainActor
func createProject(name: String) {
do { _ = try repo.createProject(name: name) }
catch { print("Create failed: \(error)") }
}

@MainActor
func deleteProjects(ids: [UUID]) {
do { try repo.deleteProjects(ids) }
catch { print("Delete failed: \(error)") }
}
}

struct ProjectListScreen: View {
@Environment(\.modelContext) private var context
// Use @Query for reactive UI; map to display models as needed
@Query(sort: \Project.createdAt, order: .reverse, animation: .snappy)
private var projects: [Project]

// DI: resolve from environment or container
private let viewModel: ProjectListViewModel

init(container: ModelContainer? = nil) {
// In a real app, use a DI container. Here we bootstrap from environment when available.
if let container {
self.viewModel = ProjectListViewModel(repo: SwiftDataProjectsRepository(container: container))
} else {
// Will be replaced in .onAppear when environment container is known
self.viewModel = ProjectListViewModel(repo: SwiftDataProjectsRepository(container: try! ModelContainer(for: [Project.self, Task.self])))
}
}

@State private var newName = ""

var body: some View {
NavigationStack {
List {
ForEach(projects, id: \.persistentModelID) { project in
NavigationLink(project.name) {
TaskListScreen(project: project)
}
}
.onDelete(perform: delete)
}
.navigationTitle("Projects")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
let name = newName.isEmpty ? "Untitled" : newName
viewModel.createProject(name: name)
newName = ""
} label: {
Image(systemName: "plus")
}
}
}
.safeAreaInset(edge: .bottom) {
HStack {
TextField("New project name", text: $newName)
.textFieldStyle(.roundedBorder)
Button("Add") {
let name = newName.isEmpty ? "Untitled" : newName
viewModel.createProject(name: name)
newName = ""
}
}
.padding()
.background(.bar)
}
}
}

private func delete(offsets: IndexSet) {
let ids = offsets.compactMap { projects[$0].id } // our domain UUID on Project
viewModel.deleteProjects(ids: ids)
}
}

struct TaskListScreen: View {
@Environment(\.modelContext) private var context
let project: Project

@Query private var tasks: [Task]
@State private var title: String = ""

init(project: Project) {
self.project = project
// Filter tasks for this project by its UUID
_tasks = Query(filter: #Predicate<Task> { $0.project?.id == project.id },
sort: [SortDescriptor(\.title)])
}

var body: some View {
List {
ForEach(tasks, id: \.persistentModelID) { task in
HStack {
Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
.onTapGesture {
task.isDone.toggle()
try? context.save()
}
Text(task.title)
}
}
.onDelete(perform: delete)
}
.safeAreaInset(edge: .bottom) {
HStack {
TextField("New task", text: $title)
.textFieldStyle(.roundedBorder)
Button("Add") {
guard !title.isEmpty else { return }
let t = Task(title: title, project: project)
context.insert(t)
try? context.save()
title = ""
}
}
.padding()
.background(.bar)
}
.navigationTitle(project.name)
}

private func delete(offsets: IndexSet) {
for i in offsets { context.delete(tasks[i]) }
try? context.save()
}
}

Key takeaways:

  • Use @Query for lists to get automatic UI updates and batched animations.
  • Write operations should call context.save() explicitly (don’t rely solely on autosave behavior).
  • ViewModels orchestrate intent and call repositories; views remain thin.

Unit testing SwiftData with ModelContainer

Question: “How do you unit test SwiftData models in SwiftUI?”

Use an in‑memory ModelContainer for fast, isolated tests. Inject it into your repository or feature service.

import XCTest
import SwiftData
@testable import ProjectsApp

final class SwiftDataProjectsRepositoryTests: XCTestCase {
var container: ModelContainer!
var repo: ProjectsRepository!

override func setUpWithError() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(for: [Project.self, Task.self], configurations: config)
repo = SwiftDataProjectsRepository(container: container)
}

override func tearDownWithError() throws {
container = nil
repo = nil
}

func testCreateAndFetchProjects() throws {
_ = try repo.createProject(name: "Alpha")
_ = try repo.createProject(name: "Beta")

let projects = try repo.allProjects()
XCTAssertEqual(projects.count, 2)
XCTAssertEqual(projects.map(\.name).sorted(), ["Alpha", "Beta"])
}

func testAddTask() throws {
let p = try repo.createProject(name: "Alpha")
let t = try repo.addTask(to: p.id, title: "Do work", dueDate: nil)
XCTAssertEqual(t.projectId, p.id)
}
}

Notes:

  • Keep ModelContainer local to the test to avoid cross‑test coupling.
  • Use repositories in tests to validate mapping and persistence logic.
  • For UI tests, inject a pre‑seeded, in‑memory container into previews or test app targets.

SwiftData preview container setup in SwiftUI

Question: “swiftdata preview container setup swiftui”

Create a pre‑populated, in‑memory container for previews.

struct ProjectListScreen_Previews: PreviewProvider {
static var previewContainer: ModelContainer = {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: [Project.self, Task.self], configurations: config)
let context = ModelContext(container)

let demo = Project(name: "Demo")
demo.tasks = [Task(title: "Explore SwiftData", project: demo),
Task(title: "Write blog", isDone: true, project: demo)]
context.insert(demo)
try? context.save()

return container
}()

static var previews: some View {
ProjectListScreen(container: previewContainer)
.modelContainer(previewContainer)
}
}

This keeps previews fast and realistic.

SwiftData best practices for scalability and performance

  • Context isolation

    • Never share a ModelContext across threads or actors.
    • Create a fresh ModelContext per unit of work. It’s cheap and safer.
  • Writes and transactions

    • Call save() explicitly after mutations.
    • Batch large imports and save periodically to bound memory.
  • Don’t pass @Model across concurrency domains

    • Pass a UUID or persistentModelID between threads/actors and re‑fetch in the target context.
    • If you need to reload by id: use a predicate on your domain UUID or use the context’s model(for:) with the persistent ID.
  • Fetching and memory

    • Use predicates and sort descriptors to limit result sets.
    • Consider fetch limits or paging for very large tables.
  • Undo and background work

    • Disable undoManager on background/batch contexts to reduce memory.
  • Delete rules

    • Choose cascade/nullify intentionally to avoid orphan records or accidental data loss.
  • Schema evolution

    • Plan additive, backward‑compatible changes when possible.
    • Test migrations on real data before shipping. Keep DTOs decoupled to reduce ripple effects.
  • DI and modularization

    • Inject ModelContainer and repositories. Keep features in separate modules for long‑term maintainability.

Troubleshooting and common pitfalls

  • Concurrency violations

    • Symptom: runtime warnings/crashes about accessing a model from the wrong actor.
    • Fix: confine all ModelContext usage to a single actor/thread. Re‑fetch models in the target context using domain IDs.
  • “No model found” or fetch returns empty

    • Ensure your @Model types are listed in the ModelContainer configuration.
    • Verify you saved the context after inserts.
  • UI not updating after writes

    • Make sure you’re saving the same store the view reads from (i.e., same ModelContainer).
    • If not using @Query, manually refresh state after background updates (e.g., re‑fetch in ViewModel).
  • Crashes on delete

    • Check relationship deleteRule and inverse. Missing or incorrect inverse can cause inconsistencies.
  • Preview data doesn’t show

    • Ensure the preview view uses .modelContainer(previewContainer), not the app’s default container.
    • Seed data before returning the container.

FAQ

How to structure SwiftData in a clean architecture?

  • Keep @Model types in the data layer.
  • Expose repositories with domain DTOs and business‑friendly APIs.
  • Use dependency injection to give ViewModels the repository(s).
  • Use @Query for simple, reactive lists; push all writes through repositories or services.
  • Isolate background work in a dedicated actor (or a ModelActor) and never share ModelContext across actors.

How to perform background tasks in SwiftData using ModelActor?

  • Create an actor that owns a ModelContainer and instantiates a ModelContext per job.
  • Perform inserts/updates within the actor and call save() in batches.
  • Pass IDs between actors, not @Model instances. Re‑fetch on the background actor’s context.
  • If you prefer, use Apple’s ModelActor to synthesize context access; the concurrency rules stay the same.

How do you unit test SwiftData models in SwiftUI?

  • Use ModelContainer with ModelConfiguration(isStoredInMemoryOnly: true) inside XCTest.
  • Inject the container into repositories/services under test.
  • Seed data in a test context and verify fetches/mutations.
  • For preview/testing UI, pass a pre‑populated in‑memory container via .modelContainer(_:)

Putting it together: why this swiftdata architecture works with SwiftUI

This “swiftdata architecture swiftui” approach scales because:

  • SwiftUI stays reactive and simple with @Query.
  • Repositories make your data layer testable and keep persistence concerns contained.
  • Background actors provide safe, predictable concurrency for heavy work.
  • DI and DTOs keep modules independent and migrations manageable.

Key takeaways and next steps

  • Use a single ModelContainer at the app boundary; inject it.
  • Confine ModelContext to a single actor; create one per unit of work.
  • Prefer repositories and DTOs for a clean architecture; keep @Model out of business logic.
  • Use @Query for read‑heavy views; save explicitly on writes.
  • Test with an in‑memory ModelContainer; set up a seeded preview container.

Next steps:

  • Add a UUID to Task to fully decouple DTOs from SwiftData internals.
  • Introduce a background import service using your actor and wire progress to the UI.
  • If your app grows, split features into modules and keep repositories per feature.

By following these SwiftData best practices, you get a scalable, testable, and performant SwiftUI data layer you can maintain for years - without sacrificing the developer ergonomics SwiftData brings to the table.