Best Practice: Using Static Methods in Swift for Instance Initialization?

Hello!

I am learning to program vis-a-vis Swift (the language itself) and SwiftUI (framework).

I have completed most of the offical tutorials offered by Apple BUT I struggle to know when code I got to work by deviating from their pattern is actually good or not(please don't judge, I am still learning)

For example: In a bid to incoporate encapsulation, I moved implementation details for each modelcontainer into their respective files.

Example of a UserModel:

public final class UserDBImplementation {
	private let container: ModelContainer

	public init() throws {
		self.container = try UserDBImplementation.buildContainer()
	}

	static private var currentAppEnv: String? {
		return Bundle.main.infoDictionary?["APP_ENV"] as? String
	}


	static private func buildContainer() throws -> ModelContainer {
		guard let appENV = currentAppEnv else {
			let res = try createInMemoryContainer()
			return res
		}

		return try createContainer(for: appENV)
	}


	static private func createInMemoryContainer() throws -> ModelContainer {
		let configuration = ModelConfiguration(for: UserModel.self, isStoredInMemoryOnly: true)
		return try ModelContainer(for: UserModel.self, configurations: configuration)
	}

	static private func createContainer(for appEnv: String) throws -> ModelContainer {
		if appEnv == "Staging" {// Release && TestFlight
			let configuration =  buildConfiguration(for: appEnv)
			let container = try ModelContainer(for: UserModel.self, configurations: configuration)
			return container
		} else if appEnv == "Production" { // Release && AppStore 
				let configuration =  buildConfiguration(for: appEnv)
				let container = try ModelContainer(for: UserModel.self, configurations: configuration)
				return container
		} else {
				let container = try createInMemoryContainer()
				return container
		}
	}

	static private func buildConfiguration(for appEnv: String) -> ModelConfiguration {
		let fileName = appEnv.appending(".sqlite")

		let fileURL = FileManager.default.urls(
			for: .documentDirectory, in: .userDomainMask
		).first!.appendingPathComponent(fileName)
			
		return ModelConfiguration(url: fileURL)
	}
}

I am concerned about the use of Static methods to do the work required to initialize an instance. Searching online doesn't yield a lot of information BUT this Stackover which advocates for it under certain conditions; conditions which I think UserDBImplementation meets.

Any feedback would be greatly apprecaited. Thank you!

Well, in general there is nothing wrong with using static methods during init. If initialization of an object is complex, that's better than having large init. But there might be room for improvement, for example, if you want to reuse this initialization, or this initialization is not intended to be part of the object (e.g. object has to do a lot of other things), you can leverage factory patten and extract this into separate type:

public struct UserModelContainerFactory {
    public init() {
    }

    func make() -> ModelContainer {
        let currentAppEnv = Bundle.main.infoDictionary?["APP_ENV"] as? String
        guard let appENV = currentAppEnv else {
	        let res = try createInMemoryContainer()
	        return res
        }
        return try createContainer(for: appENV)
    }

    private func createInMemoryContainer() throws -> ModelContainer {
        let configuration = ModelConfiguration(for: UserModel.self, isStoredInMemoryOnly: true)
        return try ModelContainer(for: UserModel.self, configurations: configuration)
    }

    private func createContainer(for appEnv: String) throws -> ModelContainer {
        if appEnv == "Staging" { // Release && TestFlight
            let configuration =  buildConfiguration(for: appEnv)
            let container = try ModelContainer(for: UserModel.self, configurations: configuration)
            return container
        } else if appEnv == "Production" { // Release && AppStore 
            let configuration =  buildConfiguration(for: appEnv)
            let container = try ModelContainer(for: UserModel.self, configurations: configuration)
            return container
        } else {
            let container = try createInMemoryContainer()
            return container
        }
    }

    private func buildConfiguration(for appEnv: String) -> ModelConfiguration {
        let fileName = appEnv.appending(".sqlite")
        let fileURL = FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)
            .first!
            .appendingPathComponent(fileName)
        return ModelConfiguration(url: fileURL)
    }
}

public final class UserDBImplementation {
	private let container: ModelContainer

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

let factory = UserModelContainerFactory()
let db = UserDBImplementation(container: factory.make())

So now you have separated construction of container from usage. If other type (say, PostDBImplementation) would need same container construction, you can make this a generic and reuse:

public struct ModelContainerFactory<ModelType> where ModelType: PersistentModel {
    // ...
}
2 Likes

The Swift API Design Guidelines page is a great reference for learning naming conventions and API design and style.

The conventions section includes:

  • Begin names of factory methods with “make”, e.g. x.makeIterator().

So your buildContainer() static method would more closely follow the guidelines as makeContainer().

4 Likes

If you're going to reuse initialization logic, then static methods are an option. But the simplest code and tightest encapsulation for this kind of thing comes from an inline closure. Any closure which is executed this way could be named makeX, substituting the name of the thing on the left of the =, for X.

public init() throws {
  container = try .init(
    for: UserModel.self,
    configurations: {
      var inMemory: ModelConfiguration {
        .init(for: UserModel.self, isStoredInMemoryOnly: true)
      }

      guard let appEnv = Bundle.main.infoDictionary?["APP_ENV"] as? String else {
        return inMemory
      }

      return switch appEnv {
      case
        "Staging", // Release && TestFlight
        "Production" // Release && AppStore
        : .init(
          url: .documentsDirectory.appending(path: appEnv).appendingPathExtension("sqlite")
        )
      default: inMemory
      }
    } ()
  )
}

A make can be thought of a semantic specialization of of an init. So when needing to move one to an outer scope, I prefer a private extension initializer to a static method, when possible.

  public init() throws {
    container = try .init(for: UserModel.self, configurations: .init())
  }
}

private extension ModelConfiguration {
  init() {
    var inMemory: Self {
      .init(for: UserModel.self, isStoredInMemoryOnly: true)
    }

    self = switch Bundle.main.infoDictionary?["APP_ENV"] as? String {
    case let appEnv?:
      switch appEnv {
      case
        "Staging", // Release && TestFlight
        "Production" // Release && AppStore
        : .init(
          url: .documentsDirectory.appending(path: appEnv).appendingPathExtension("sqlite")
        )
      default: inMemory
      }
    case nil: inMemory
    }
  }
}
1 Like

Thank you so much for your feedback - the point about refactoring for reusability is particularly useful as it allows for use of generics. Appreciate your time

1 Like

Thank you for the pointing to the reference - will make use of this moving forward.

1 Like

Thank you for taking the time to read and provide feedback; your statement:

A make can be thought of a semantic specialization of an init

is very succinct.

1 Like