Patterns: Command + Siri

Ahmed RamyAhmed Ramy
7 min read

Yes, it’s me again. I know you’re shocked, so am I. This post totally breaks my record of ‘one-and-done’ blog posts. 🤷‍♂️

I’m seriously surprised I’ve followed through. But hey, whenever the magical words Software Design, Optimization, Time-saving, and Automation get thrown into the mix, my gears start turning. We’re about to merge everything we love—Command Patterns, reuse, composability, and… Siri—for a healthy dose of engineering craziness.

In our previous post on the Command Pattern, we explored how the pattern looks in both MVC and MVVM, using UIKit and SwiftUI. We discussed how it helps us avoid the dreaded “Massive View Controller,” and how easy it is to shape everything like Legos to create reusable, composable building blocks. Perfect for containing your precious business logic in a tidy package.

Siri as a New UI (Looking at It from the Right Angle)

Today, we’re highlighting how Siri can become a new UI if you think about it from the right angle. With iOS 16, Siri can be managed via AppIntents—more flexible than ever—and the same code can also be used with the Shortcuts app.

If you were to say, “Hey Siri, use my app to fuel my car,” you could hook into your internal business logic that’s already wrapped inside a Command Pattern. That means Siri is basically another entry point to a well-structured domain. Let’s see how.

AppShortcutsProvider: Teaching Siri What to listen to

Below is a snippet of how you can register shortcuts with Siri. You create an AppShortcutsProvider, define the phrases users might say, and supply an Intent for Siri to call.

@available(iOS 16.0, *)
class SiriShortcutsProvider: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: OrderFuelIntent(),
            phrases: [
                "Fuel my car",
                "Fuel my car at \(.$location)"
            ],
            shortTitle: "Order Fuel",
            systemImageName: "fuelpump"
        )
    }
}

OrderFuelIntent

struct OrderFuelIntent: AppIntent {
// ...
    static var title: LocalizedStringResource = "Order Fuel"
    static var description = IntentDescription(
        "Quickly make a Fuel Order with Previously Used Vehicles and Fuel Settings based on latest Order at Home or Work",
        categoryName: "Fuel Ordering",
        searchKeywords: ["fuel", "order"]
    )

    @Parameter(
        title: "Location",
        requestValueDialog: "Where should the order be?",
        optionsProvider: SiriIntentLocationOptionsProvider()
    )
    var location: Location
// ...
}

Here, location is a parameter that Siri will prompt for when needed. Once you’ve set up the necessary data, your perform function is where the magic happens.

Adding the Location Enum

Below, you can see how we define the Location enum so Siri knows what “Home” or “Work” actually means in your domain model:

@available(iOS 16.0, *)
enum Location: String, CaseIterable {
    case home = "Home"
    case work = "Work"
}

// MARK: AppEnum

@available(iOS 16.0, *)
extension Location: AppEnum {
    public static var typeDisplayRepresentation: TypeDisplayRepresentation {
        TypeDisplayRepresentation(name: "Order Location")
    }

    public static var caseDisplayRepresentations: [Location: DisplayRepresentation] {
        [
            .home: DisplayRepresentation(title: "Home"),
            .work: DisplayRepresentation(title: "Work"),
        ]
    }
}

@available(iOS 16.0, *)
extension Location {
    func mapToDomainModel() -> AddressType {
        switch self {
        case .home:
            return .home
        case .work:
            return .work
        }
    }
}
🤔
You might be wondering, why mapToDomainModel, that’s because by isolating 3rd party entities and wrap them into our own, makes us agnostic of their changes, we covered it in more details while discussing Apple Pay during Error Handling

Closer look at the perform() function

Wait a minute…, when you look at the perform function

    func perform() async throws -> some IntentResult & ProvidesDialog {
        // Logic Execution here
    }

It doesn’t have any parameters, it executes logic based on the struct’s parameters… this looks familiar

This looks like our AsyncUsecase from previously on Patterns!

struct FetchPaymentMethodsUsecase: AsyncUsecase { 
    func execute() async -> Result<[PaymentMethod], BusinessError> {
        // 1. Fetch payment methods
        // 2. Fetch wallet
        // 3. Preselect a favorite
        // 4. Disable unsupported methods
        // 5. Add Apple Pay (if supported)
    }
}

When you call execute(), this use case already knows what to do. Siri’s perform() method is basically the same structure—it even returns a type that’s constrained to IntentResult.

Putting It All Together with Siri

When you think about it this way… perform can be a gathering ground for all the usecases to formulate new functionality!

func perform() async throws -> some IntentResult & ProvidesDialog {
    // Step 1: Update session with the selected delivery address
    await UpdateUserAddressUsecase(newAddress: location.asDomainModel())
        .execute()

    // Step 2: Fetch the fuel type for the selected vehicle (98, 95, Diesel, or EV)
    let fuelType = try await GetFuelTypeForVehicleUsecase()
        .execute()
        .mapError { SiriIntentError(reason: $0.errorMessage).whileLogging() }
        .get()

    // Step 3: Fetch the selected vehicle
    // session is a means of Dependency Injection to mark user's selected preferences
    guard let vehicle = session.selectedVehicles.first else {
        throw SiriIntentError(reason: "Couldn't find your selected vehicle")
    }

    // Step 4: Fetch the payment method
    guard let paymentMethod = payment.session.availablePaymentMethods
        .first(where: { $0 is SiriPayable }) else {
        throw SiriIntentError(reason: "Couldn't find a Siri compatible method to pay with")
    }

    // Step 5: Set up the order with a full tank of fuel
    let fuelQuantity: Quantity = .fullTank
    let product = CAFUProduct(fuel: fuelType, quantity: fuelQuantity)
    session.order.products = [product]

    // Step 6: Get the nearest available time slot for fuel delivery
    let timeslot = try await NearestTimeSlotUseCase(variantId: fuelType.variantId)
        .execute()
        .mapError { SiriIntentError(reason: $0.errorMessage).whileLogging() }
        .get()

    product.timeSlot = timeslot
    payment.select(paymentMethod: paymentMethod)

    // Step 7: Create a new order
    try await CreateOrderUsecase()
        .execute()

    // Step 8: Display to the user the order summary
    try await requestConfirmation(
        result: .result(
            view: OrderSummaryView()
        ),
        confirmationActionName: .order,
        showPrompt: false
    )

    // Step 9: Place the order after confirmation
    try await PlaceOrderUsecase()
        .execute()

    // Return success message
    let confirmationMessage = "Awesome! Your fuel order has been placed."
    return .result(dialog: IntentDialog(stringLiteral: confirmationMessage))
}

Notable Observations

  1. We rely on multiple use cases (Commands) such as UpdateUserAddress, GetFuelTypeForVehicleUsecase, etc.

  2. Each use case has a single execute() method which encapsulates a distinct piece of business logic.

  3. Siri calls perform(), which, in turn, calls our chain of commands. This is the definition of modular and reusable code.

How does it look like?

🤖
There goes our girl, ordering on the go 🥹

Now About Testing 🧪

One of the biggest advantages of using the Command Pattern (and a clean architecture approach) is how testable it all becomes. Here’s how you can tackle testing at various levels:

1. Testing Each Use Case

  • Unit Tests: For each use case (e.g., GetFuelTypeForVehicleUsecase, UpdateUserAddressUsecase), you can write straightforward unit tests that verify the business logic in isolation.

    • Mock Dependencies: If your use case depends on a repository or a network service, inject a mock or fake. This ensures you test the logic without hitting a real backend.

    • Error Cases: Test how each use case handles different failure scenarios (e.g., “No internet,” “Invalid vehicle ID,” etc.).

func testGetFuelTypeForVehicle() async throws {
    // Given
    let vehicle = Vehicle.stub(...)
    let sut = GetFuelTypeForVehicleUsecase(vehicle: vehicle, repository: MockFuelRepository())

    // When
    let result = await sut.execute()

    // Then
    switch result {
    case .success(let fuelType):
        XCTAssertEqual(fuelType, .gas98)
    case .failure(let error):
        XCTFail("Expected success, got \(error) instead")
    }
}
💡
You can use SwiftyMocky for it to mock your protocols directly, saves you lots of boilerplate code in the future!

2. Testing the Siri Integration

  • Integration Tests: Since Siri’s perform() acts as an entry point that orchestrates multiple use cases, you can run an integration test to confirm that everything plays nicely together.

    • Mock the Siri Environment: Apple doesn’t provide a direct unit test harness for SiriKit or AppIntents in Xcode. However, you can abstract away the domain logic from the Siri layer. Then, test the domain logic in a normal Xcode test target.

    • Automated UI Testing (Optional): If you want to test the voice-driven flow, you might consider an automated test setup (like UI tests), but that can get tricky. Usually, verifying your domain logic + making sure your Siri Intents are valid with the “Shortcuts” integration is enough.

3. Testing the Flow with Dependency Injection

  • Dependency Injection: Notice in the example code, we keep referencing session or payment. In production, these might be singletons or injected services. For tests, you can provide test doubles—like TestSession or MockPaymentService—to control the data.

  • Verifying Side Effects: If CreateOrderUsecase triggers a network call or updates a database, you can check your mock objects to see if the correct methods were called with the right parameters.

Conclusion

The Command Pattern is powerful. By encapsulating each piece of business logic into its own “command,” we avoid cluttering our UI or Siri integration with domain knowledge. Siri (thanks to AppIntents) is just another consumer of our well-structured domain. That means:

  • Easier maintenance: We only need to manage business logic in one place.

  • Better testing: Each Command/Usecase can be tested independently.

  • Less code duplication: The same commands are called whether you’re interacting via the app’s UI or Siri.

0
Subscribe to my newsletter

Read articles from Ahmed Ramy directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ahmed Ramy
Ahmed Ramy

I am an iOS Developer from Egypt, Cairo who likes Apple Products and UX studies I enjoy reading about Entreprenuership and Scrum. When no one is looking, I like to play around with Figma and build tools/ideas to help me during get things done quickly. And I keep repeating epic gaming moments over and over again in my head and repeat narratives out loud in my room to regain a glimpse of how it felt when I first saw it (without altering the script)