Dependency Injection Dengan Pendekatan Functional
Mar 19, 2020 02:39 ยท 1695 words ยท 8 minute read
Istilah Dependency Injection (DI) biasa digunakan untuk merujuk pada teknik dimana suatu object menyuplai dependencies dari object lain. Tujuan utamanya adalah agar object tersebut tidak terikat pada satu implementasi tertentu saja, membuatnya menjadi lebih fleksibel terhadap perubahan. Istilah ini cukup dikenal dalam dunia Object-Oriented Programming.
Berikut contoh kecil DI:
interface FileSystemService {
exists: (path: Path) => boolean;
create: (path: Path, content: string) => void;
}
class AppGenerator {
constructor(private fsService: FileSystemService) {} // [1]
initProject(projectName: Path) {
if (this.fsService.exists(projectName)) { // [2]
throw new Error('Cannot create project. A file/dir already exists!')
}
this.fsService.create(projectName, "Generated content") // [2]
}
}
// Later when calling
import fs from 'fs'
const fsService: FileSystemService = {
exists: fs.existsSync,
create: fs.writeFileSync,
}
const app = new AppGenerator(fsService); // [3]
app.initProject('my-awesome-project')
- Kita definisikan dependency di constructor class [1] dan berikan constraint dengan sebuah interface agar implementasi object
fsService
dapat diganti-ganti sesuka hati selama comply dengan interface tersebut. - Dependency dapat diakses dengan keyword
this
[2]. - Dependency diberikan ketika object
AppGenerator
diinstansiasi [3].
Dengan pemodelan dependency lewat class constructor ini, class AppGenerator
menjadi lebih fleksibel dan tidak terikat pada satu implementasi saja. We program to an interface, not implementation. Detil implementasi dapat diganti semisal ketika testing:
const fsMock: FileSystemService = {
exists: jest.fn().mockReturnValue(false),
create: jest.fn(),
};
const app = new AppGenerator(fsMock)
app.initProject('my-awesome-project')
expect(fsMock.create).toHaveBeenCalledWith('my-awesome-project')
Testing jadi jauh lebih mudah dan gak perlu berinteraksi dengan file system beneran. AppGenerator
cukup tau apa yang perlu dilakukan namun tak perlu tau bagaimana melakukannya. Sering juga disebut dengan Inversion of Control.
Pertanyaannya: bagaimana jika dilakukan dengan pendekatan functional? Apakah bisa dilakukan tanpa adanya class? Cukup dengan function?
Explicit Dependencies
Kita dapat melihat pada contoh di atas bahwa method initProject
bergantung pada object fsService
yang kemudian diakses melalui magic keyword “this”. Bicara dalam konteks function, kita tak lagi dapat mengandalkan keyword “this” dan harus mencari cara lain agar tetap bisa mengakses fsService
.
Satu-satunya cara adalah dengan menyuplai fsService
langsung melalui function argument. Kenyataannya pure function memang mengharuskan kita untuk memberi semua data yang diperlukan melalui argument. Sehingga DI tetap bisa dilakukan dengan cara ini.
Well, technically dependencies == function arguments.
function initProject(fsService, projectName) {
^^^^^^^^^
if (fsService.exists(projectName)) {
throw new Error('Cannot create project. A file/dir already exists!')
}
fsService.create(projectName, "Generated content")
}
No more this
! Dependency didefinisikan langsung di function argument tanpa mengabaikan substitutability object fsService
.
initProject(fsService, 'my-awesome-project')
// or
initProject(fsMock, 'my-awesome-project')
Factory Function
Anggap saja requirement berubah di kemudian hari. User butuh fitur logging [1] untuk mengetahui apakah program berhasil dijalankan atau tidak. Lalu ada tambahan options [2] juga agar user dapat mengkostumasi project dengan lebih leluasa.
initProject(fsService, logService, projectName, options) {
^^^^^^^^^^ ^^^^^^^
[1] [2]
logService.log('Initiating project...')
if (fsService.exists(projectName) && !options.overwrite) {
logService.error('Cannot create project. A file/dir already exists!')
return
}
fsService.create(projectName, "Generated content")
logService.success(`Successfully created project ${projectName}`)
}
Fungsi initProject
kini membutuhkan dua buah external service (fsService
dan logService
) dan dua buah argument “biasa” (projectName
dan options
). Pada dasarnya mereka semua sama-sama function argument, hanya concern-nya saja yang berbeda. Kita bisa melakukan sedikit refactor untuk membuat external service terlihat lebih eksplisit dan terpisah dari yang lainnya dengan factory function.
function makeInitProject(fsService, logService) { // dependencies
return function initProject(projectName, options) { // "normal" arguments
// ...
}
}
// later
const initProject = makeInitProject(fsService, consoleService)
initProject('my-awesome-project', { overwrite: true })
makeInitProject
menerima dua buah service dan mengembalikan fungsi initProject
yang menerima argument “biasa”. makeInitProject
seolah berperan sebagai constructor
dalam menerima dan menyimpan dependencies dengan bantuan closure. Namun esensinya pendekatan ini tidaklah berbeda dengan solusi sebelumnya.
Di artikel Functional Approach to Dependency Injection si penulis juga menggunakan pendekatan ini yang bisa dicapai dengan partial application jika bahasanya mendukung currying, seperti F#.
Di artikel lain seperti Dependency Injection using Modules yang ditulis menggunakan ReasonML, juga memanfaatkan ide yang sama.
-- // The "interface"
module type Logger = {
let log: string => unit;
};
-- // The "implementation"
module ConsoleLogger = {
let log = loggable => Js.Console.log(log);
};
-- // The "consumer"
module GreetingService = (LOG: Logger) => {
let sayHello = name => LOG.log("Hello " ++ name);
};
-- // Inject!
module ConsoleGreeter = GreetingService(ConsoleLogger);
ConsoleGreeter.sayHello("World");
Who needs class when you have functions ๐
Saya ingin menambahkan satu pendekatan lain yang mirip dengan factory function: yaitu type class.
Type Class
Cara lain agar DI dapat diimplementasikan dengan cara functional adalah melalui type class. Beberapa yang mendukung fitur type class diantaranya Haskell, Idris, dan Purescript. Type class sendiri adalah kumpulan-kumpulan method tanpa implementasi โ seperti interface โ yang memungkinkan tercapainya ad-hoc polymorphism.
So, how does it look like?
type Path = String
type Content = String
class Monad m <= FsService m where -- [1]
exists :: Path -> m Boolean
create :: Path -> Content -> m Unit
initProject :: โ m.
FsService m => -- [2]
String -> m Unit
initProject projectName = do
doesExist <- exists projectName -- [3a]
if doesExist
then pure unit
else create projectName "Generated content" -- [3b]
- Pertama, kita harus definisikan type class
FsService
[1] yang memiliki dua buah method:exists
dancreate
. Berikanm
constraint Monad agar nantinya bisa kita berikan instanceEffect
(atauAff
untuk komputasi asynchronous) dan menjalankan real side-effect (IO operation). - Berikan constraint
FsService
[2] pada functioninitProject
agar kita bisa memanggil kedua buah methodFsService
[3a, 3b] di dalamnya.
Dibandingkan dengan pendekatan-pendekatan sebelumnya di atas, kita tidak melihat dependency fsService
terdefinisikan di function argument, hanya constraint class FsService
di type signature saja. Lalu bagaimana kita bisa meng-inject implementasi konkrit dari class FsService
kalau fungsi initProject
sendiri tidak menerimanya di argument?
HINT ๐ก:
Compiler yang menyuplainya saat compile time
Yang harus kita lakukan saat ini hanyalah membuat implementasi (instance) dari type class FsService
semisal:
import Prelude
import Effect (Effect)
import Effect.Aff (Aff)
import Effect.Class (liftEffect)
import Node.Buffer as Buffer
import Node.Encoding (Encoding(..))
import Node.FS.Aff as FS
instance fsServiceAff :: FsService Aff where -- [1]
exists path = FS.exists path -- [2]
create path content = do -- [2]
FS.mkdir path
buffer <- liftEffect $ Buffer.fromString content UTF8
FS.writeFile (path <> "/README.md") buffer
Mari bahas step-by-step:
- Kita memberikan
Aff
โ sebuah Monad yang merepresentasikan komputasi asynchronous โ instanceFsService
[1] dan beri nama instance tersebutfsServiceAff
. Ini berarti methodexists
dancreate
bisa kita panggil di dalam konteks Aff (we’ll do it below ๐๐ผ). - Implementation details [2]. Jika dipanggil, program akan berinteraksi dengan file system.
Tinggal satu langkah tersisa: tempatkan function initProject
ke dalam konteks Aff
agar compiler dapat meng-inject instance fsServiceAff
. Kita bisa menggunakan fungsi launchAff_
.
import Effect.Aff (launchAff_)
main :: Effect Unit
main = launchAff_ do
initProject "my-awesome-project"
Langkah barusan sangat penting karena jika tidak, compiler tidak akan bisa melakukan DI. Seandainya kita tempatkan fungsi initProject
misal ke dalam konteks Effect
seperti main = pure $ initProject "my-awesome-project"
, compiler akan gagal mencari instance FsService Effect
karena kita memang belum membuatnya: “No type class instance was found for FsService Effect
.”
Kembali ke konteks Aff
. Jika kita intip output hasil compile-nya, kita akan melihat bagaimana compiler melakukan DI untuk kita secara otomatis:
var FsService = function (exists, create) {
this.exists = exists;
this.create = create;
};
var fsServiceAff = new FsService(function () {
return ...;
})
var initProject = function (dictFsService) { // Argument untuk menerima service (dep)
return function (projectName) { // Argument "biasa"
return ...;
};
};
var main = Effect_Aff.launchAff_(
initProject(fsServiceAff)("my-awesome-project") // ๐ฅ Inject `fsServiceAff`
^^^^^^^^^^^^
);
Bisa dicoba di link ini.
Dependency Baru
Sejauh ini kita dapat menyimpulkan bahwa compiler melakukan dependency injection saat compile time hanya dengan mendeklarasikan constraint type class pada type signature. Artinya ketika kita punya service tambahan e.g logService
dan promptService
, kita hanya perlu mengubah type signature-nya dan voila, dependencies ter-inject.
-- Service baru
data LogType = Debug | Error | Success
class Monad m <= LogService m where
log :: LogType -> String -> m Unit
class Monad m <= PromptService m where
prompt :: String -> m String
-- Buat instance-nya
instance logServiceAff :: LogService Aff where
log = ...
instance promptServiceAff :: PromptService Aff where
prompt = ...
-- Tambah constraint di type signature-nya
initProject :: โ m.
FsService m =>
LogService m => -- ๐๐ป
PromptService m => -- ๐๐ป
String -> m Unit
initProject projectName = do
Hasil compile-nya:
var initProject = function (dictFsService) {
return function (dictLogService) {
return function (dictPromptService) {
return function (projectName) {
return ...;
};
};
};
};
var main = Effect_Aff.launchAff_(
initProject(fsServiceAff)(logServiceAff)(promptServiceAff)("my-awesome-project")
^^^^^^^^^^^^ ^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
);
Nice! ๐ ๐ ๐
Ganti Implementasi
Di awal artikel saya menyebutkan salah satu benefit DI adalah kemampuan mengganti detil implementasi service tanpa perlu mengubah code yang menggunakannya. Dalam hal ini type class juga bisa diinstansiasi oleh tipe data lain. Contohnya ketika testing, kita ingin membuat “spy” terhadap method FsService
dengan memanfaatkan Writer monad.
-- Berikan instance `FsService` kepada `Writer`
instance fsServiceWriter :: FsService (Writer [String] a) where
exists path = path /= "my-awesome-project"
create path _ = tell [path]
spec = describe "initProject" do
it "creates a project" do
-- Jalankan `initProject` di dalam konteks `Writer`,
-- dengan menempatkannya di dalam fungsi `execWriter`
let calls = execWriter (initProject "my-awesome-project") in
calls `shouldEqual` ["my-awesome-project"]
it "fails creating a project" do
let calls = execWriter (initProject "invalid") in
calls `shouldEqual` []
No problems whatsoever.
Putting it All Together
Dengan pattern ini, kita tidak lagi melakukan Dependency Injection dengan menyuplainya secara eksplisit lewat function argument seperti solusi di awal artikel. Kita mengalihkannya ke type signature. Biar compiler yang menuntaskan pekerjaan Dependency Injection-nya. Dari segi estetika, dependencies dan function arguments juga terpisah jelas, sesuai goal awal kita.
Menurut saya, ini win-win solution untuk masalah DI ๐
To sum up, this may be our final code:
main :: Effect Unit
main = launchAff_ program
program :: โ m.
FsService m =>
LogService m =>
PromptService m =>
m Unit
program = do
projectName <- prompt "What's your project name: "
when (not $ null projectName) do
initProject projectName
Wrap up
Kita tetap bisa melakukan DI dengan hanya menggunakan functions. Factory function dapat membantu penulisan kode menjadi lebih jelas mana dependency (service) dan mana function argument biasa. Dan ternyata pendekatan ini cukup lumrah di bahasa-bahasa functional lainnya.
Saya harap artikel ini bermanfaat dalam menambah wawasan teman-teman sekalian. Terima kasih banyak. Stay well ๐