We all study architecture patterns, but real projects rarely give us perfect conditions to apply them. Deadlines, pressure, and unexpected changes often push architecture aside.
Yet good architecture is what prevents rewrites, regressions, and chaos later. So I’m starting this series to share the architectural patterns and principles I’ve been exploring, focusing on what truly holds up in real-world iOS development.
Command Query Separation Principle:
Originally introduced by Bertrand Meyer, the idea can be explained in the simplest possible terms
If a function returns something, it should not change anything.
If a function changes something, it should not return anything.
-
A Query should only return a result and must not produce side-effects, meaning it does not alter the system’s observable state.
-
A Command performs an action that does change the system’s state, but it should not return a value.
This separation keeps your code predictable, testable, and easier to reason about.
Common violation in ViewModels
func fetchUsers() -> [User] {
self.isLoading = true // side-effect
let fetchRequest: NSFetchRequest<User> = User.fetchRequest()
let users = (try? context.fetch(fetchRequest)) ?? []
self.cachedUsers = users // side-effect
self.isLoading = false // side-effect
return users // returns data
}
Note: Direct Core Data access is used here only for demonstration. In real projects, this should go through a Repository layer.
What’s wrong here?
This method does two jobs:
- This function returns something (users).
- It also changes state (isLoading, cachedUsers).
This mixes responsibilities, violates CQS, and makes the function harder to predict and reuse.
How to apply CQS correctly
private var cachedUsers: [User] = []
// Command: Mutates state, returns nothing
func fetchUsers() {
isLoading = true
Task {
do {
let users = try await userRepository.fetch()
self.cachedUsers = users
self.isLoading = false
} catch {
self.error = error
self.isLoading = false
}
}
}
// Query: Returns data, mutates nothing
func getCachedUsers() -> [User] {
cachedUsers
}
Now we have clear separation:
- One function is a command (changes state, returns nothing).
- One function is a query (returns data, changes nothing).
Why this helps
- Clear separation of responsibilities improves code readability and reasoning.
- Easier to unit test each method for its intended effect.
- When queries don’t mutate, you can call them from any thread without surprising state corruption. Commands become your synchronization points.
Limitations
CQS can get tricky in multithreaded environments. If commands and queries access the same data without proper synchronization, it can lead to race conditions or inconsistent reads.
For example:
// Thread 1
viewModel.fetchUsers() // Writing to cachedUsers
// Thread 2 (simultaneously)
let users = viewModel.getCachedUsers() // Reading cachedUsers
This can produce inconsistent data if both run simultaneously.
In modern iOS apps, ViewModels often use @MainActor to ensure all state changes and queries happen on the main thread. This guarantees that both commands and queries execute in a predictable order, avoiding race conditions. CQS improves architecture, but it doesn’t replace concurrency safety. So applying it blindly can lead to unexpected issues.
When to Bend the Rules
Even though CQS is helpful, some exceptions are completely normal.
Stack operations
A stack’s pop() removes an item and returns it. This breaks CQS, but it’s completely normal because that’s how stacks are designed.
Database inserts
Sometimes a method needs to change something and return helpful metadata.
func saveImage(_ image: UIImage) -> URL? {
// Command: Writes file to disk (changes state)
guard let data = image.jpegData(compressionQuality: 0.8) else {
return nil
}
// Get the documents directory
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
guard let directory = documentsDirectory else { return nil }
let fileURL = directory.appendingPathComponent("photo.jpg")
// Write file
try? data.write(to: fileURL)
return fileURL // Query: Returns the path where it was saved
}
The URL you get back isn’t a “query” of existing data. It’s simply telling you where the new file was saved. It’s information about the action you just performed, so it doesn’t violate the idea behind CQS.
Builder /Fluent APIs:
Fluent APIs return the same object so you can chain calls:
Text("Hello")
.font(.title) // Returns modified view
.foregroundColor(.blue) // Returns modified view
.padding() // Returns modified view
Each method returns a modified version of the same object. This doesn’t violate the “spirit” of CQS because the return value is just convenient syntax.
The simple rule to decide
Before breaking CQS, ask: “Will returning this value make the method confusing or unpredictable?” If no, you’re good.
Final Thoughts
CQS is one of those principles that seems simple but changes how you design functions forever. By separating commands and queries, you build a codebase that’s easier to test, explain, and evolve.




