Best practices for separating dependencies with SwiftPM?

I'm taking an app of mine and breaking it up into several smaller Swift packages. The app is almost completely protocol-driven so I can do protocol-based dependency injection in nearly every component.

As I've been splitting it up, I've been including the protocol and implementation in the same package, but this is leading to large dependency graphs for components that really only need the protocols.

I've been considering creating separate "protocol packages" that are basically just the protocols of a particular feature and the implementation in another package, ie:

- UserServiceProtocol package
  - few dependencies, should just be other protocol packages
- UserService package
  - imports UserServiceProtocol
  - other implementation dependencies (potentially lots of dependencies)
- FeatureA package
  - imports UserServiceProtocol, but not UserService

Has anyone else done similar things or have thoughts / suggestions for this? I'm a bit concerned about having to add that many more packages, but for the most part, they shouldn't change much.

1 Like

I recommend keeping them in the same package, but in separating them into different products. SwiftPM should then be able to skip the unused dependencies in the future. The relevant optimizations have been implemented, they just have not been released yet.

In general, think of a package as a node on the dependency graph where versioning occurs, and group things into packages based on whether or not you want them versioned together. Then think of a product as a node on the dependency graph where inclusion occurs, and group things into products based on whether a client should be able to include or exclude them as a unit.

4 Likes

Oh wow that's a great solution! Thanks! So eventually, SwiftPM will only clone the dependencies that are actually being used in your consumer instead of everything listed in the package's dependencies?

If anyone is interested in what I did to get this working. Is this the correct way, Jeremy?

In the library

Set up your directory structure:

Sources
- UserService // implementation
  - UserService.swift
- UserServiceProtocols // protocols product
  - UserServiceProtocols.swift

Add a target and a product to Package.swift:

products: [
  .library(name: "UserService", targets: ["UserService"]),
  // add a `UserServiceProtocols` product
  .library(name: "UserServiceProtocols", targets: ["UserServiceProtocols"]),
],
targets: [
  .target(name: "UserServiceProtocols"), <-- add a target for the protocols
  .target(name: "UserService", dependencies: [
    "UserServiceProtocols", <-- make the protocols a dependency of the implementation
    ... other deps ... ]),
]

In the consumer

In the consuming package, include it in dependencies, then in your targets use .product(name:package:) to include just the protocols:

dependencies: [
  .package(path: "../UserService"),
],
targets: [
  .target(name: "FooComponent", dependencies: [
    .product(
      name: "UserServiceProtocols", <-- specify the protocol product
      package: "UserService")           from the UserService package
  ])
]
1 Like

Yes, exactly.

Will this approach work for things like shared models? I tried doing this, which I would think should work, but I'm getting a cyclical dependency error:

UserService package
- target: UserServiceProtocols
- target: UserService (implementation)
  - depends on FooServiceProtocols (below)
  - depends on UserServiceProtocols & UserServiceModels
- target: UserServiceModels

FooService package
- target: FooServiceProtocols
- target: FooService (implementation)
  - depends on UserServiceModels

Does cyclical dependency detection happen at the package level or the target level?

This could just be a case of rethinking the architecture :sweat_smile:

I am not sure if that sort of arrangement was ever considered. (Versioning those packages would be a pain.) The yet‐to‐be‐released resolution implementation would probably permit a client to depend on UserService or FooService, but not both. But that just follows from how its algorithm works, and there might be a separate check somewhere that outright disallows it.

Regardless, according to the less precise algorithm in the current release of Swift, that is a dependency cycle, and there is no way to dodge it. You will have to find a way to put one package cleanly below the other.

Okay thanks. Yeah sorry, that was kind of a bad example and the more I thought about it, I think the models in this case make sense as an independent package, not included in UserService. There are a few different things that can use/manage the model data so it's not really "owned" by the UserService.

The combining of protocols and implementations as products, though is :100: !

A somewhat related question. As I'm building out SwiftUI previews in my packages, I find myself wanting to use some of the dependency implementations to generate data for the previews. The best practice seems to be putting the SwiftUI preview in the same file as its implementation, but in this case, would necessitate bringing in the implementation into the target where I only wanted the protocol.

One approach I've been thinking of is using the product approach and put my previews in a separate product. This would satisfy the dependency issue, but then you lose the convenience of the preview right next to the implementation.

Another approach is creating mock implementations of the protocols to completely disconnect the implementation. This solves the dependency issue, but I lose some of the convenience if I wanted to just use the implementation for the preview.

Do you have any thoughts/best practices about how to approach SwiftUI preview data?

I have not put all that much thought into it. If the relevant imports are already needed anyway, I tend to keep previews right in the file. When they are not, I tend to move the previews out into an unexposed target so no clients are affected by the additional dependencies. Then I put the two files side‐by‐side in split view while I am working, even though the screen seems more crowded.

If you find a better strategy, I would be curious to hear back.