Open the starter playground. You’ll find the system method already implemented. It uses two types from the sources folder for demo purposes: SimulatedFile and SimulatedView. You won’t need to make any changes to those two types. Build and run. It writes our log file.
Now, take a look at the system method:
class System {
var currentFileNameNumber = 5
var activeView = SimulatedView()
func doMainOperation(num1: Int, num2: Int) {
let sum = num1 + num2 // 1
let stringSum = "\(sum)" // 2
let formatter = NumberFormatter() // 3
formatter.locale = Locale(identifier: "ar")
let localizedSum = formatter.string(from: NSNumber(integerLiteral: sum))!
let attributes: [NSAttributedString.Key : Any] = [ // 4
NSAttributedString.Key.foregroundColor: UIColor.blue,
NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 16),
NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue
]
let styledString = NSAttributedString(string: localizedSum, attributes: attributes)
activeView.labelText = localizedSum // 5
let activeFileName = "logFile\(currentFileNameNumber).log" // 6
var logFileHandler = SimulatedFile(fileName: activeFileName)
logFileHandler.openFile()
if logFileHandler.numberOfLines >= 100 { // 7
currentFileNameNumber += 1
let newActiveFileName = "logFile\(currentFileNameNumber).log"
logFileHandler.closeFile()
logFileHandler = SimulatedFile(fileName: activeFileName)
logFileHandler.openFile()
}
let logEntry = "\(num1) + \(num2) = \(sum). Presented: \(localizedSum)" // 8
logFileHandler.writeData(logEntry) // 9
logFileHandler.closeFile() // 10
}
}
You can see that it’s not comfortable to read.
Looking at the code, you can identify this method’s different responsibilities. The math operation. The String operations. Styling the string. And log management.
The system should be putting those capabilities together, not the method. So now, you’ll start refactoring this large method to extract classes that each have a clear focus.
Start with the simplest — the math operation. Add this new class at the end of the playground:
class Calculator {
}
Calculator, in this system, performs only one operation: It adds two numbers.
class Calculator {
class func add(num1: Int, num2: Int) -> Int {
num1 + num2
}
}
Implement that function and use it in doMainOperation
func doMainOperation(num1: Int, num2: Int) {
let sum = Calculator.add(num1: num1, num2: num2)
...
Next, create StringManager to take care of the localization. Since the Foundation framework already handles the conversion from Int to String quite effectively through string interpolation, there’s no need to transition to a class for this task.
class StringManager {
class func toLocalizedValue(num: Int) -> String {
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "ar")
let localizedSum = formatter.string(from: NSNumber(integerLiteral: num))!
print(localizedSum)
return localizedSum
}
}
This converts the number to a string in Arabic letters. Use the new method in your system:
func doMainOperation(num1: Int, num2: Int) {
let sum = Calculator.add(num1: num1, num2: num2)
let stringSum = "\(sum)"
let localizedSum = StringManager.toLocalizedValue(num: sum) // new code
The system’s role in applying styling to the string is problematic, as this task is tied to the user interface. Ideally, the system should avoid incorporating any UI-specific code. Furthermore, views should remain passive, focusing solely on binding data to display elements.
To fix this, you need something between the system and the view to take care of this formatting and styling. Create the new class ViewPresenter to take care of the formatting and pass the new value to the view:
class ViewPresenter {
class func showValueOnView(value: String, view: SimulatedView) {
let attributes: [NSAttributedString.Key : Any] = [
NSAttributedString.Key.foregroundColor: UIColor.blue,
NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 16),
NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue
]
let styledString = NSAttributedString(string: value, attributes: attributes)
view.labelText = styledString
}
}
Use the new method in your system and keep the line that creates the log entry:
func doMainOperation(num1: Int, num2: Int) {
let sum = Calculator.add(num1: num1, num2: num2)
let stringSum = "\(sum)"
let localizedSum = StringManager.toLocalizedValue(num: sum)
ViewPresenter.showValueOnView(value: localizedSum, view: activeView) // new code
let logEntry = "\(num1) + \(num2) = \(sum). Presented: \(localizedSum)"
...
Now, the missing parts in your system are all related to logging. The system shouldn’t know how the logging works, what the limit per file is, how logs are written, etc. Instead, the only thing it should know is that it needs to pass the entry to a logging manager that will take care of those details.
So your next step is to design that LogManager:
class LogManager {
public func addLogEntry(_ entry: String) {
}
}
From the old system implementation, LogManager should be responsible for:
- Handling log files.
- Verifying the sizes of log files.
- Writing log entries.
Also, wouldn’t it be cleaner to have a singular manager for logs across your whole system as it grows? It makes sense to implement the Singleton pattern on LogManager right?
Here’s how you do that:
class LogManager {
private static var currentInstance: LogManager?
private init() {
}
class func singleton() -> LogManager {
if currentInstance == nil {
currentInstance = .init()
}
return currentInstance!
}
...
}
Now, for the complete implementation.
class LogManager {
private static var currentInstance: LogManager?
private static let maxLogSize = 100 // new code
private var currentFileNameNumber = 5
private var logFileHandler: SimulatedFile!
private init() {
let activeFileName = fileName(numbered: currentFileNameNumber)
logFileHandler = SimulatedFile(fileName: activeFileName)
logFileHandler.openFile()
}
class func singleton() -> LogManager {
if currentInstance == nil {
currentInstance = .init()
}
return currentInstance!
}
public func addLogEntry(_ entry: String) {
verifyLogSize()
print("\(entry) | Log entry saved.")
}
private func fileName(numbered: Int) -> String { // new code
"logFile\(numbered).log"
}
private func verifyLogSize() { // new code
if logFileHandler.numberOfLines >= LogManager.maxLogSize_const {
currentFileNameNumber += 1
logFileHandler.closeFile()
let newFileName = fileName(numbered: currentFileNameNumber)
logFileHandler = SimulatedFile(fileName: newFileName)
logFileHandler.openFile()
}
}
}
At this point, the only thing your system knows — or needs to know — about LogManager is that it needs to call LogManager.singleton().addLogEntry(logEntry). LogManager manages all the details about handling and validating the files.
To implement that, add the following line to the end of doMainOperation(::):
func doMainOperation(num1: Int, num2: Int) {
...
let logEntry = "\(num1) + \(num2) = \(sum). Presented: \(localizedSum)"
LogManager.singleton().addLogEntry(logEntry) // new code
Build and run. Look at that. The playground values are all the same as before.
The updated init() ensures that the instance of LogManager is ready to be used right away. You’ve separated fileName(:) out into a new method. After all, since verifyLogSize() and init() create filename strings to access those files, you don’t want to copy/paste code in different places. Finally, you moved the verification code into a separate method.
If you want to, you can break down verifyLogSize() even further, and even use a different implementation to achieve the same result. However, the principle remains the same. By following the Single Responsibility principle, you’ve made doSomething() more organized.
You now have a proper LogManager that you can use all across your different system operations. You can change the logging, localization and presentation of data on the screen in many ways without affecting the system itself. Your changes will be isolated and less risky.
Each class controls whatever side effects it has, and the system knows minimum information about each type. If one of them breaks, it’ll be easy to identify what went wrong; fixing one element won’t affect the others.