Skip to content

Latest commit

 

History

History
161 lines (110 loc) · 5.8 KB

best-practices.md

File metadata and controls

161 lines (110 loc) · 5.8 KB

Best Practices

This document outlines a list of suggestions to make your code more testable and to make Relay more powerful.


Architecture

The first step to writing testable code is following Object-Oriented design principles. Specifically, we aim to adhere to SOLID principles. At minimum, you'll need to familiarize yourself with the D in SOLID, which stands for the Dependency Inversion Principle. Similarly, but not a SOLID principle, you'll need to understand Inversion of Control, which is the foundation of Relay.

Dependency Inversion Principle

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

Breakdown

Try to structure your code by interface. When importing a module, or even referencing another class or struct, what is it that the referencing class truly needs from the imported type, and can it be defined in an abstract interface?

Example

/// Bad ❌
final class MyViewController {
  private let dataStore = MyViewControllerDataStore()
}

final class MyViewControllerDataStore {
  private let backendService = MyBackendService()
}
/// Good ✅
protocol MyViewControllerDataStoreType {
  func fetchBackingData(completion: @escaping ([MyViewModel]) -> Void)
}

protocol MyBackendServiceType {
  func fetchServiceData(completion: @escaping ([DomainModel]) -> Void)
}

final class MyViewController {

  private let dataStore: MyViewControllerDataStoreType
  ...

}

final class MyViewControllerDataStore: MyViewControllerDataStoreType {

  private let backendService: MyBackendServiceType
  ...

}

Takeaways

  • Depend on abstract interfaces (protocols) so that implementation is interchangeable
  • Abstractions should be independent (also see: Interface Segregation Principle)

Inversion of Control Principle

Inversion of Control (IoC) is a design principle in which custom-written portions of a computer program receive the flow of control from a generic framework.

Breakdown

By following this principle, you put the main control of your program into the hands of an external, generic framework; sometimes, even outside of the program itself. This principle is similar to Dependency Inversion in that common design approaches achieve the same goals.

To adhere to these principles, Relay utilizes IoC Containers and Dependency Injection. For detailed information, see Relay Architecture.

Example

/// Without Dependency Injection ❌
final class MyViewController {
  private let dataStore: MyViewControllerDataStoreType = MyViewControllerDataStore()
}

final class MyViewControllerDataStore {
  private let backendService: MyBackendServiceType = MyBackendService()
}
/// With Dependency Injection ✅
final class MyViewController {

  private let dataStore: MyViewControllerDataStoreType

  init(dataStore: MyViewControllerDataStoreType) {
    self.dataStore = dataStore
  }

}

final class MyViewControllerDataStore: MyViewControllerDataStoreType {

  private let backendService: MyBackendServiceType

  init(backendService: MyBackendServiceType) {
    self.backendService = backendService
  }

}

Relay Usage

Relay is a very hands-on framework. Follow these general patterns to get the most value out of Relay while maintaining clean, readable code.

Project Setup

Relay is a dynamic dependency injection framework. From a testing scenario, this means that it is necessary only for the system under test (your application or program) and the driver (ex. a UI Test target).

For Xcode projects, Relay must be linked to the main application target and any UI Test targets that need to inject dependencies. For SwiftPM projects, Relay must be a dependency for the target executable and any integration test targets that need to inject dependencies.

Dependency Keys

Type and Factory keys should be visible to both the application target and driver targets. The actual types and factories, however, should only be visible to your application target. To enforce this separation, you can define keys in standalone extensions:

/// DependencyKeyExtensions.swift

import Relay

extension DependencyTypeKey {
  static let myViewControllerDataStore = DependencyTypeKey("MyApp.MyViewControllerDataStoreType")
  static let myBackendService = DependencyTypeKey("MyApp.MyBackendServiceType")
  /// etc.
}

extension DependencyFactoryKey {
  static let myViewControllerDataStore = DependencyFactoryKey("MyApp.MyViewControllerDataStore")
  static let myViewControllerDataStoreStub = DependencyFactoryKey("MyApp.MyViewControllerDataStore.Stub")
  static let myBackendService = DependencyFactoryKey("MyApp.MyBackendService")
  static let myBackendServiceStub = DependencyFactoryKey("MyApp.MyBackendService.Stub")
  /// etc.
}

Naming

Type keys should be named using the fully-qualified name of the type. Remember that types and factories must not be linked to your driver target, so these strings must be manually defined:

extension DependencyTypeKey {
  static let format = DependencyTypeKey("[BundleName].[TypeName]")
  static let example = DependencyTypeKey("MyApp.SampleType")
}

Similarly, factory keys should be named using the fully-qualified name of the concrete type, optionally appended by the purpose of the factory. A single type may have many potential resolvable factories.

extension DependencyFactoryKey {
  static let format = DependencyTypeKey("[BundleName].[TypeName].[Purpose]")
  static let example1 = DependencyTypeKey("MyApp.Sample")
  static let example2 = DependencyTypeKey("MyApp.Sample.MyAppUITests.MyViewControllerTests.testBehavesWithStubbedData")
}