Tentang apa ini?
Setelah mengerjakan proyek yang berbeda, saya perhatikan bahwa masing-masing dari mereka memiliki beberapa masalah umum, terlepas dari domain, arsitektur, konvensi kode dan sebagainya. Masalah-masalah itu tidak menantang, hanya rutinitas yang membosankan: memastikan Anda tidak melewatkan sesuatu yang bodoh dan jelas. Alih-alih melakukan rutinitas ini setiap hari, saya menjadi terobsesi untuk mencari solusi: beberapa pendekatan pengembangan atau konvensi kode atau apa pun yang akan membantu saya merancang proyek dengan cara yang akan mencegah masalah itu terjadi, sehingga saya dapat fokus pada hal-hal menarik . Itulah tujuan dari artikel ini: untuk menggambarkan masalah-masalah itu dan menunjukkan kepada Anda campuran alat dan pendekatan yang saya temukan untuk menyelesaikannya.
Masalah yang kita hadapi
Saat mengembangkan perangkat lunak, kami menghadapi banyak kesulitan: persyaratan yang tidak jelas, miskomunikasi, proses pengembangan yang buruk, dan sebagainya.
Kami juga menghadapi beberapa kesulitan teknis: kode lama memperlambat kami, penskalaan rumit, beberapa keputusan buruk di masa lalu menendang gigi kami hari ini.
Semuanya bisa jika tidak dihilangkan kemudian dikurangi secara signifikan, tetapi ada satu masalah mendasar yang tidak dapat Anda lakukan: kompleksitas sistem Anda.
Gagasan tentang sistem yang Anda kembangkan sendiri selalu kompleks, apakah Anda memahaminya atau tidak.
Bahkan ketika Anda membuat aplikasi CRUD lain , selalu ada beberapa kasus tepi, beberapa hal rumit, dan dari waktu ke waktu seseorang bertanya, "Hei, apa yang akan terjadi jika saya melakukan ini dan ini dalam keadaan seperti ini?" dan Anda berkata, "Hm, itu pertanyaan yang sangat bagus."
Kasus rumit, teduh logika, validasi dan pengelolaan akses - semua itu menambah ide besar Anda.
Cukup sering bahwa gagasan itu sangat besar sehingga tidak cocok di satu kepala, dan fakta itu sendiri membawa masalah seperti miskomunikasi.
Tetapi mari kita bermurah hati dan berasumsi bahwa tim pakar domain dan analis bisnis ini berkomunikasi dengan jelas dan menghasilkan persyaratan konsisten yang baik.
Sekarang kita harus mengimplementasikannya, untuk mengekspresikan ide rumit itu dalam kode kita. Sekarang kode itu adalah sistem lain, jauh lebih rumit daripada ide asli yang ada dalam pikiran kita.
Bagaimana bisa begitu? Menghadapi kenyataan: keterbatasan teknis memaksa Anda untuk menangani beban tinggi, konsistensi data, dan ketersediaan selain menerapkan logika bisnis yang sebenarnya.
Seperti yang Anda lihat tugasnya cukup menantang, dan sekarang kami membutuhkan alat yang tepat untuk menghadapinya.
Bahasa pemrograman hanyalah alat lain, dan seperti halnya alat lainnya, ini bukan hanya tentang kualitasnya, itu mungkin lebih banyak lagi tentang alat yang sesuai dengan pekerjaan. Anda mungkin memiliki obeng terbaik, tetapi jika Anda perlu memasukkan paku ke kayu, palu jelek akan lebih baik, bukan?
Aspek teknis
Sebagian besar bahasa populer saat ini berorientasi objek. Ketika seseorang membuat pengantar untuk OOP mereka biasanya menggunakan contoh:
Pertimbangkan sebuah mobil, yang merupakan objek dari dunia nyata. Ini memiliki berbagai sifat seperti merek, berat, warna, kecepatan maks, kecepatan saat ini dan sebagainya.
Untuk mencerminkan objek ini dalam program kami, kami mengumpulkan properti-properti itu dalam satu kelas. Properti bisa permanen atau bisa berubah, yang bersama-sama membentuk keadaan saat ini dari objek ini dan beberapa batas di mana ia dapat bervariasi. Namun menggabungkan sifat-sifat itu tidak cukup, karena kita harus memeriksa bahwa keadaan saat ini masuk akal, misalnya kecepatan saat ini tidak melebihi kecepatan maks. Untuk memastikan bahwa kami melampirkan beberapa logika ke kelas ini, tandai properti sebagai pribadi untuk mencegah siapa pun membuat keadaan ilegal.
Seperti yang Anda lihat benda adalah tentang keadaan internal dan siklus hidup mereka.
Jadi ketiga pilar OOP masuk akal dalam konteks ini: kami menggunakan warisan untuk menggunakan kembali manipulasi negara tertentu, enkapsulasi untuk perlindungan negara dan polimorfisme untuk memperlakukan objek serupa dengan cara yang sama. Mutabilitas sebagai default juga masuk akal, karena dalam konteks ini objek yang tidak dapat diubah tidak dapat memiliki siklus hidup dan selalu memiliki satu keadaan, yang bukan kasus yang paling umum.
Masalahnya adalah ketika Anda melihat aplikasi web khas hari ini, itu tidak berurusan dengan objek. Hampir semua yang ada dalam kode kita memiliki masa hidup yang abadi atau tidak memiliki masa hidup yang baik sama sekali. Dua jenis "objek" yang paling umum adalah semacam layanan seperti UserService
, EmployeeRepository
atau beberapa model / entitas / DTO atau apa pun namanya. Layanan tidak memiliki keadaan logis di dalamnya, mereka mati dan dilahirkan kembali persis sama, kami hanya membuat kembali grafik ketergantungan dengan koneksi database baru.
Entitas dan model tidak memiliki perilaku yang menyertainya, mereka hanya kumpulan data, ketidakmampuan mereka tidak membantu tetapi justru sebaliknya.
Oleh karena itu fitur utama OOP tidak terlalu berguna untuk mengembangkan aplikasi semacam ini.
Apa yang terjadi dalam aplikasi web biasa adalah data mengalir: validasi, transformasi, evaluasi, dan sebagainya. Dan ada paradigma yang sangat cocok untuk pekerjaan semacam itu: pemrograman fungsional. Dan ada bukti untuk itu: semua fitur modern dalam bahasa populer saat ini berasal dari sana: async/await
, lambdas dan delegate, pemrograman reaktif, serikat yang didiskriminasi (enum dengan cepat atau karat, tidak perlu bingung dengan enum di java atau .net ), tuple - semua itu dari FP.
Namun itu hanya hancur, itu sangat bagus untuk memilikinya, tetapi ada lebih banyak, lebih banyak lagi.
Sebelum saya melangkah lebih dalam, ada satu hal yang harus dibuat. Beralih ke bahasa baru, terutama paradigma baru, adalah investasi untuk pengembang dan karenanya untuk bisnis. Melakukan investasi bodoh tidak akan memberi Anda apa pun selain masalah, tetapi investasi yang masuk akal mungkin adalah hal yang akan membuat Anda bertahan.
Banyak dari kita lebih suka bahasa dengan pengetikan statis. Alasannya sederhana: kompiler menangani pemeriksaan yang membosankan seperti melewatkan parameter yang tepat ke fungsi, membangun entitas kita dengan benar dan seterusnya. Cek ini gratis. Sekarang, untuk hal-hal yang tidak dapat diperiksa oleh kompiler, kami punya pilihan: berharap yang terbaik atau melakukan beberapa tes. Tes menulis berarti uang, dan Anda tidak membayar hanya sekali per tes, Anda harus mempertahankannya. Selain itu, orang menjadi ceroboh, jadi sesekali kita mendapatkan hasil positif palsu dan negatif palsu. Semakin banyak tes yang Anda tulis, semakin rendah kualitas rata-rata tes tersebut. Ada masalah lain: untuk menguji sesuatu, Anda harus tahu dan ingat bahwa hal itu harus diuji, tetapi semakin besar sistem Anda semakin mudah untuk melewatkan sesuatu.
Namun kompiler hanya sebagus jenis sistem bahasa. Jika tidak memungkinkan Anda untuk mengekspresikan sesuatu dengan cara statis, Anda harus melakukannya saat runtime. Yang artinya tes, ya. Ini bukan hanya tentang jenis sistem, sintaks dan fitur gula kecil juga sangat penting, karena pada akhirnya kami ingin menulis kode sesedikit mungkin, jadi jika beberapa pendekatan mengharuskan Anda untuk menulis sepuluh kali lebih banyak baris, yah, tidak ada yang akan menggunakannya. Itulah mengapa penting bahwa bahasa yang Anda pilih memiliki serangkaian fitur dan trik yang pas - well, fokus yang tepat secara keseluruhan. Jika tidak - alih-alih menggunakan fitur-fiturnya untuk melawan tantangan asli seperti kompleksitas sistem Anda dan mengubah persyaratan, Anda juga akan melawan bahasa. Dan semuanya bergantung pada uang, karena Anda membayar pengembang untuk waktu mereka. Semakin banyak masalah yang harus mereka selesaikan, semakin banyak waktu yang mereka butuhkan dan semakin banyak pengembang yang akan Anda butuhkan.
Akhirnya kita akan melihat beberapa kode untuk membuktikan semua itu. Saya kebetulan adalah pengembang .NET, jadi sampel kode akan menggunakan C # dan F #, tetapi gambaran umum akan terlihat kurang lebih sama dalam bahasa OOP dan FP populer lainnya.
Biarkan pengkodean dimulai
Kami akan membangun aplikasi web untuk mengelola kartu kredit.
Persyaratan dasar:
- Buat / Baca pengguna
- Buat / Baca kartu kredit
- Aktifkan / Nonaktifkan kartu kredit
- Tetapkan batas harian untuk kartu
- Saldo isi ulang
- Pembayaran proses (mempertimbangkan saldo, tanggal kedaluwarsa kartu, status aktif / dinonaktifkan, dan batas harian)
Demi kesederhanaan kami akan menggunakan satu kartu per akun dan kami akan melewati otorisasi. Tetapi untuk sisanya kita akan membangun aplikasi yang mampu dengan validasi, penanganan kesalahan, database dan api web. Jadi mari kita mulai dengan tugas pertama kita: mendesain kartu kredit.
Pertama, mari kita lihat seperti apa bentuknya di C #
public class Card { public string CardNumber {get;set;} public string Name {get;set;} public int ExpirationMonth {get;set;} public int ExpirationYear {get;set;} public bool IsActive {get;set;} public AccountInfo AccountInfo {get;set;} } public class AccountInfo { public decimal Balance {get;set;} public string CardNumber {get;set;} public decimal DailyLimit {get;set;} }
Tapi itu tidak cukup, kita harus menambahkan validasi, dan umumnya itu dilakukan di beberapa Validator
, seperti yang ada di FluentValidation
.
Aturannya sederhana:
- Nomor kartu diperlukan dan harus berupa string 16 digit.
- Nama diperlukan dan harus hanya berisi huruf dan dapat berisi spasi di tengah.
- Bulan dan tahun harus memenuhi batas.
- Info akun harus ada saat kartu aktif dan tidak ada saat kartu dinonaktifkan. Jika Anda bertanya-tanya mengapa, itu sederhana: ketika kartu dinonaktifkan, seharusnya tidak mungkin untuk mengubah saldo atau batas harian.
public class CardValidator : IValidator { internal static CardNumberRegex = new Regex("^[0-9]{16}$"); internal static NameRegex = new Regex("^[\w]+[\w ]+[\w]+$"); public CardValidator() { RuleFor(x => x.CardNumber) .Must(c => !string.IsNullOrEmpty(c) && CardNumberRegex.IsMatch(c)) .WithMessage("oh my"); RuleFor(x => x.Name) .Must(c => !string.IsNullOrEmpty(c) && NameRegex.IsMatch(c)) .WithMessage("oh no"); RuleFor(x => x.ExpirationMonth) .Must(x => x >= 1 && x <= 12) .WithMessage("oh boy"); RuleFor(x => x.ExpirationYear) .Must(x => x >= 2019 && x <= 2023) .WithMessage("oh boy"); RuleFor(x => x.AccountInfo) .Null() .When(x => !x.IsActive) .WithMessage("oh boy"); RuleFor(x => x.AccountInfo) .NotNull() .When(x => x.IsActive) .WithMessage("oh boy"); } }
Sekarang ada beberapa masalah dengan pendekatan ini:
- Validasi dipisahkan dari deklarasi tipe, yang berarti untuk melihat gambaran lengkap dari kartu apa yang sebenarnya harus kita navigasikan melalui kode dan membuat ulang gambar ini di kepala kita. Ini bukan masalah besar ketika itu terjadi hanya sekali, tetapi ketika kita harus melakukan itu untuk setiap entitas dalam proyek besar, yah, itu sangat memakan waktu.
- Validasi ini tidak dipaksakan, kita harus ingat untuk menggunakannya di mana-mana. Kami dapat memastikan ini dengan tes, tetapi sekali lagi, Anda harus mengingatnya saat Anda menulis tes.
- Ketika kami ingin memvalidasi nomor kartu di tempat lain, kami harus melakukan hal yang sama lagi. Tentu, kami dapat menyimpan regex di tempat yang sama, tetapi kami masih harus menyebutnya di setiap validator.
Dalam F # kita bisa melakukannya dengan cara yang berbeda:
(**) type CardNumber = private CardNumber of string with member this.Value = match this with CardNumber s -> s static member create str = match str with | (null|"") -> Error "card number can't be empty" | str -> if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok else Error "Card number must be a 16 digits string" (**) type CardAccountInfo = | Active of AccountInfo | Deactivated (**) type Card = { CardNumber: CardNumber Name: LetterString //
Tentu saja beberapa hal dari sini bisa kita lakukan di C #. Kita dapat membuat kelas CardNumber
yang akan melempar ValidationException
di sana juga. Tetapi trik dengan CardAccountInfo
tidak dapat dilakukan dalam C # dengan cara mudah.
Hal lain - C # sangat bergantung pada pengecualian. Ada beberapa masalah dengan itu:
- Pengecualian memiliki "pergi ke" semantik. Satu saat Anda di sini dalam metode ini, yang lain - Anda berakhir di beberapa penangan global.
- Mereka tidak muncul dalam tanda tangan metode. Pengecualian seperti
ValidationException
atau InvalidUserOperationException
adalah bagian dari kontrak, tetapi Anda tidak tahu itu sampai Anda membaca implementasi . Dan itu masalah besar, karena cukup sering Anda harus menggunakan kode yang ditulis oleh orang lain, dan alih-alih membaca hanya tanda tangan, Anda harus menavigasi sampai ke bagian bawah tumpukan panggilan, yang membutuhkan banyak waktu.
Dan inilah yang mengganggu saya: setiap kali saya menerapkan beberapa fitur baru, proses implementasi itu sendiri tidak memakan banyak waktu, sebagian besar pergi ke dua hal:
- Membaca kode orang lain dan mencari tahu aturan logika bisnis.
- Memastikan tidak ada yang rusak.
Ini mungkin terdengar seperti gejala desain kode yang buruk, tetapi hal yang sama terjadi pada proyek yang ditulis dengan baik.
Oke, tapi kita bisa mencoba menggunakan hasil yang sama di C #. Implementasi yang paling jelas akan terlihat seperti ini:
public class Result<TOk, TError> { public TOk Ok {get;set;} public TError Error {get;set;} }
dan itu adalah sampah murni, itu tidak mencegah kita dari pengaturan baik Ok
dan Error
dan memungkinkan kesalahan diabaikan sepenuhnya. Versi yang tepat akan menjadi seperti ini:
public abstract class Result<TOk, TError> { public abstract bool IsOk { get; } private sealed class OkResult : Result<TOk, TError> { public readonly TOk _ok; public OkResult(TOk ok) { _ok = ok; } public override bool IsOk => true; } private sealed class ErrorResult : Result<TOk, TError> { public readonly TError _error; public ErrorResult(TError error) { _error = error; } public override bool IsOk => false; } public static Result<TOk, TError> Ok(TOk ok) => new OkResult(ok); public static Result<TOk, TError> Error(TError error) => new ErrorResult(error); public Result<T, TError> Map<T>(Func<TOk, T> map) { if (this.IsOk) { var value = ((OkResult)this)._ok; return Result<T, TError>.Ok(map(value)); } else { var value = ((ErrorResult)this)._error; return Result<T, TError>.Error(value); } } public Result<TOk, T> MapError<T>(Func<TError, T> mapError) { if (this.IsOk) { var value = ((OkResult)this)._ok; return Result<TOk, T>.Ok(value); } else { var value = ((ErrorResult)this)._error; return Result<TOk, T>.Error(mapError(value)); } } }
Cukup rumit, bukan? Dan saya bahkan tidak mengimplementasikan versi void
untuk Map
dan MapError
. Penggunaannya akan terlihat seperti ini:
void Test(Result<int, string> result) { var squareResult = result.Map(x => x * x); }
Tidak terlalu buruk, eh? Nah, sekarang bayangkan Anda memiliki tiga hasil dan Anda ingin melakukan sesuatu dengan mereka ketika semuanya Ok
. Jahat. Jadi itu bukan pilihan.
Versi F #:
//
Pada dasarnya, Anda harus memilih apakah Anda menulis kode dalam jumlah yang wajar, tetapi kodenya tidak jelas, bergantung pada pengecualian, refleksi, ekspresi, dan "sihir" lainnya, atau Anda menulis lebih banyak kode, yang sulit dibaca, tetapi lebih tahan lama dan lurus ke depan. Ketika proyek seperti itu menjadi besar, Anda tidak bisa melawannya, tidak dalam bahasa dengan sistem tipe mirip C #. Mari kita pertimbangkan skenario sederhana: Anda memiliki beberapa entitas di basis kode Anda untuk sementara waktu. Hari ini Anda ingin menambahkan bidang wajib baru. Tentu saja Anda perlu menginisialisasi bidang ini di mana pun entitas ini dibuat, tetapi kompiler tidak membantu Anda sama sekali, karena kelas bisa berubah dan null
adalah nilai yang valid. Dan perpustakaan seperti AutoMapper
membuatnya lebih sulit. Mutabilitas ini memungkinkan kita untuk menginisialisasi sebagian objek di satu tempat, lalu mendorongnya ke tempat lain dan melanjutkan inisialisasi di sana. Itu sumber bug lain.
Sementara perbandingan fitur bahasa itu bagus, namun ini bukan tentang artikel ini. Jika Anda tertarik, saya membahas topik itu di artikel saya sebelumnya . Tetapi fitur bahasa itu sendiri seharusnya tidak menjadi alasan untuk beralih teknologi.
Jadi itu membawa kita ke pertanyaan-pertanyaan ini:
- Mengapa kita benar-benar perlu beralih dari OOP modern?
- Mengapa kita harus beralih ke FP?
Jawaban untuk pertanyaan pertama adalah menggunakan bahasa OOP umum untuk aplikasi modern memberi Anda banyak masalah, karena mereka dirancang untuk tujuan yang berbeda. Ini menghasilkan waktu dan uang yang Anda habiskan untuk melawan desain mereka bersama dengan kompleksitas perjuangan aplikasi Anda.
Dan jawaban kedua adalah bahasa FP memberi Anda cara mudah untuk merancang fitur Anda sehingga mereka bekerja seperti jam, dan jika fitur baru istirahat logika yang ada, itu memecahkan kode, maka Anda segera tahu itu.
Namun jawaban itu tidak cukup. Seperti yang ditunjukkan teman saya dalam salah satu diskusi kami, beralih ke FP akan sia-sia ketika Anda tidak tahu praktik terbaik. Industri besar kami menghasilkan banyak artikel, buku, dan tutorial tentang merancang aplikasi OOP, dan kami memiliki pengalaman produksi dengan OOP, jadi kami tahu apa yang diharapkan dari berbagai pendekatan. Sayangnya, ini bukan kasus untuk pemrograman fungsional, jadi bahkan jika Anda beralih ke FP, upaya pertama Anda kemungkinan besar akan terasa canggung dan tentu saja tidak akan memberi Anda hasil yang diinginkan: pengembangan sistem kompleks yang cepat dan tidak menyakitkan.
Nah, itulah tepatnya artikel ini. Seperti yang saya katakan, kita akan membangun aplikasi seperti produksi untuk melihat perbedaannya.
Bagaimana kita mendesain aplikasi?
Banyak ide yang saya gunakan dalam proses desain yang saya pinjam dari buku besar Domain Modeling Made Functional , jadi saya sangat menyarankan Anda untuk membacanya.
Kode sumber lengkap dengan komentar ada di sini . Secara alami, saya tidak akan memasukkan semuanya ke sini, jadi saya akan membahas poin-poin penting saja.
Kami akan memiliki 4 proyek utama: lapisan bisnis, lapisan akses data, infrastruktur dan, tentu saja, umum. Setiap solusi memilikinya, bukan?
Kami mulai dengan memodelkan domain kami. Saat ini kami tidak tahu dan tidak peduli tentang database. Ini dilakukan dengan sengaja, karena dengan mengingat basis data tertentu, kami cenderung merancang domain kami sesuai dengan itu, kami membawa hubungan entitas-tabel ini di lapisan bisnis, yang kemudian membawa masalah. Anda hanya perlu menerapkan pemetaan domain -> DAL
sekali, sementara desain yang salah akan terus-menerus mengganggu kami sampai titik kami memperbaikinya. Jadi inilah yang kami lakukan: kami membuat proyek bernama CardManagement
(sangat kreatif, saya tahu), dan segera mengaktifkan pengaturan <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
dalam file proyek. Mengapa kita membutuhkan ini? Baiklah, kita akan menggunakan banyak serikat yang didiskriminasi, dan ketika Anda melakukan pencocokan pola, kompiler memberi kami peringatan, jika kami tidak mencakup semua kemungkinan kasus:
let fail result = match result with | Ok v -> printfn "%A" v //
Dengan pengaturan ini, kode ini tidak dapat dikompilasi, yang persis seperti yang kita butuhkan, ketika kita memperluas fungsionalitas yang ada dan ingin disesuaikan di mana-mana. Hal berikutnya yang kita lakukan adalah membuat modul (dikompilasi dalam kelas statis) CardDomain
. Dalam file ini kami menjelaskan jenis domain dan tidak lebih. Ingatlah bahwa dalam F #, kode dan urutan file penting: secara default Anda hanya dapat menggunakan apa yang Anda nyatakan sebelumnya.
Jenis domain
Kami mulai mendefinisikan jenis kami dengan CardNumber
saya tunjukkan sebelumnya, meskipun kami akan membutuhkan Error
lebih praktis daripada hanya string, jadi kami akan menggunakan ValidationError
.
type ValidationError = { FieldPath: string Message: string } let validationError field message = { FieldPath = field; Message = message } (**) let private cardNumberRegex = new Regex("^[0-9]{16}$", RegexOptions.Compiled) type CardNumber = private CardNumber of string with member this.Value = match this with CardNumber s -> s static member create fieldName str = match str with | (null|"") -> validationError fieldName "card number can't be empty" | str -> if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok else validationError fieldName "Card number must be a 16 digits string"
Maka tentu saja kami mendefinisikan Card
yang merupakan jantung dari domain kami. Kita tahu bahwa kartu memiliki beberapa atribut permanen seperti nomor, tanggal kedaluwarsa dan nama pada kartu, dan beberapa informasi yang dapat diubah seperti saldo dan batas harian, jadi kami merangkum informasi yang dapat diubah dalam jenis lain:
type AccountInfo = { HolderId: UserId Balance: Money DailyLimit: DailyLimit } type Card = { CardNumber: CardNumber Name: LetterString HolderId: UserId Expiration: (Month * Year) AccountDetails: CardAccountInfo }
Sekarang, ada beberapa jenis di sini, yang belum kami nyatakan:
Uang
Kita dapat menggunakan decimal
(dan akan, tetapi tidak secara langsung), tetapi decimal
kurang deskriptif. Selain itu, dapat digunakan untuk representasi hal-hal lain selain uang, dan kami tidak ingin itu tercampur. Jadi kami menggunakan tipe type [<Struct>] Money = Money of decimal
.
Dailylimit
Batas harian dapat diatur ke jumlah tertentu atau tidak ada sama sekali. Jika ada, pasti positif. Alih-alih menggunakan decimal
atau Money
kami mendefinisikan jenis ini:
[<Struct>] type DailyLimit = private //
Ini lebih deskriptif daripada hanya menyiratkan bahwa 0M
berarti bahwa tidak ada batasan, karena itu juga bisa berarti bahwa Anda tidak dapat menghabiskan uang untuk kartu ini. Satu-satunya masalah adalah karena kami menyembunyikan konstruktor, kami tidak dapat melakukan pencocokan pola. Tapi jangan khawatir, kita bisa menggunakan Pola Aktif :
let (|Limit|Unlimited|) limit = match limit with | Limit dec -> Limit dec | Unlimited -> Unlimited
Sekarang kita dapat mencocokkan pola DailyLimit
mana-mana sebagai DU biasa.
Letterstring
Yang itu sederhana. Kami menggunakan teknik yang sama seperti di CardNumber
. Satu hal kecil: LetterString
bukan tentang kartu kredit, itu agak dan kita harus memindahkannya dalam proyek Common
dalam modul CommonTypes
. Tiba saatnya kita memindahkan ValidationError
ke tempat yang terpisah juga.
Userid
Yang itu hanyalah type UserId = System.Guid
alias type UserId = System.Guid
. Kami menggunakannya untuk deskripsi saja.
Bulan dan Tahun
Mereka juga harus pergi ke Common
. Month
akan menjadi serikat terdiskriminasi dengan metode untuk mengubahnya ke dan dari unsigned int16
, Year
akan menjadi seperti CardNumber
tetapi untuk uint16
bukan string.
Sekarang mari kita selesaikan deklarasi tipe domain kita. Kami membutuhkan User
dengan beberapa informasi pengguna dan pengumpulan kartu, kami membutuhkan operasi keseimbangan untuk pengisian dan pembayaran.
type UserInfo = { Name: LetterString Id: UserId Address: Address } type User = { UserInfo : UserInfo Cards: Card list } [<Struct>] type BalanceChange = //
Bagus, kami mendesain tipe kami dengan cara yang tidak valid tidak dapat ditampilkan. Sekarang setiap kali kita berurusan dengan instance dari salah satu dari jenis ini, kami yakin bahwa data di sana valid dan kami tidak perlu memvalidasinya lagi. Sekarang kita dapat melanjutkan ke logika bisnis!
Logika bisnis
Kami akan memiliki aturan yang tidak bisa dipecahkan di sini: semua logika bisnis akan dikodekan dalam fungsi murni . Fungsi murni adalah fungsi yang memenuhi kriteria berikut:
- Satu-satunya hal yang dilakukannya adalah menghitung nilai output. Tidak memiliki efek samping sama sekali.
- Selalu menghasilkan output yang sama untuk input yang sama.
Karenanya fungsi murni tidak membuang pengecualian, tidak menghasilkan nilai acak, tidak berinteraksi dengan dunia luar dalam bentuk apa pun, baik itu basis data atau DateTime.Now
sederhana. Sekarang. Tentu saja berinteraksi dengan fungsi tidak murni secara otomatis membuat fungsi panggilan tidak murni. Jadi apa yang akan kita terapkan?
Berikut daftar persyaratan yang kami miliki:
Aktifkan / nonaktifkan kartu
Memproses pembayaran
Kami dapat memproses pembayaran jika:
- Kartu tidak kedaluwarsa
- Kartu aktif
- Ada cukup uang untuk pembayaran
- Pengeluaran untuk hari ini belum melampaui batas harian.
Saldo isi ulang
Kami dapat menambah saldo untuk kartu aktif dan tidak kedaluwarsa.
Tetapkan batas harian
Pengguna dapat menetapkan batas harian jika kartu tidak kedaluwarsa dan aktif.
Ketika operasi tidak dapat diselesaikan kita harus mengembalikan kesalahan, jadi kita perlu mendefinisikan OperationNotAllowedError
:
type OperationNotAllowedError = { Operation: string Reason: string } //
Dalam modul ini dengan logika bisnis yang akan menjadi satu-satunya jenis kesalahan yang kita kembalikan. Kami tidak melakukan validasi di sini, jangan berinteraksi dengan basis data - hanya menjalankan operasi jika kami bisa mengembalikan OperationNotAllowedError
.
Modul lengkap dapat ditemukan di sini . Saya akan daftar di sini kasus paling sulit di sini: processPayment
. Kita harus memeriksa kedaluwarsa, status aktif / dinonaktifkan, uang yang dihabiskan hari ini dan saldo saat ini. Karena kita tidak dapat berinteraksi dengan dunia luar, kita harus memberikan semua informasi yang diperlukan sebagai parameter. Dengan begitu logika ini akan sangat mudah untuk diuji, dan memungkinkan Anda untuk melakukan pengujian berbasis properti .
let processPayment (currentDate: DateTimeOffset) (spentToday: Money) card (paymentAmount: MoneyTransaction) = //
Ini spentToday
Ini - kita harus menghitungnya dari koleksi BalanceOperation
akan kita simpan di basis data. Jadi kita perlu modul untuk itu, yang pada dasarnya akan memiliki 1 fungsi publik:
let private isDecrease change = match change with | Increase _ -> false | Decrease _ -> true let spentAtDate (date: DateTimeOffset) cardNumber operations = let date = date.Date let operationFilter { CardNumber = number; BalanceChange = change; Timestamp = timestamp } = isDecrease change && number = cardNumber && timestamp.Date = date let spendings = List.filter operationFilter operations List.sumBy (fun s -> -s.BalanceChange.ToDecimal()) spendings |> Money
Bagus Sekarang kita sudah selesai dengan semua implementasi logika bisnis, saatnya memikirkan pemetaan. Banyak tipe kami menggunakan serikat yang didiskriminasi, beberapa tipe kami tidak memiliki konstruktor publik, jadi kami tidak bisa mengekspos mereka seperti halnya ke dunia luar. Kita harus berurusan dengan serialisasi (de). Selain itu, saat ini kami hanya memiliki satu konteks terbatas dalam aplikasi kami, tetapi di kemudian hari dalam kehidupan nyata Anda ingin membangun sistem yang lebih besar dengan berbagai konteks terikat, dan mereka harus berinteraksi satu sama lain melalui kontrak publik, yang harus dapat dipahami. untuk semua orang, termasuk bahasa pemrograman lain.
Kita harus melakukan pemetaan dua arah: dari model publik ke domain dan sebaliknya. Sementara pemetaan dari domain ke model cukup maju, arah lain memiliki sedikit acar: model dapat memiliki data yang tidak valid, setelah semua kita menggunakan tipe polos yang dapat diserialisasi ke json. Jangan khawatir, kita harus membangun validasi kami dalam pemetaan itu. Fakta bahwa kami menggunakan tipe berbeda untuk data dan data yang mungkin tidak valid, itu selalu berarti valid, bahwa kompiler tidak akan membiarkan kami lupa untuk melakukan validasi.
Begini tampilannya:
(**) type ValidateCreateCardCommand = CreateCardCommandModel -> ValidationResult<Card> let validateCreateCardCommand : ValidateCreateCardCommand = fun cmd -> (**) result { let! name = LetterString.create "name" cmd.Name let! number = CardNumber.create "cardNumber" cmd.CardNumber let! month = Month.create "expirationMonth" cmd.ExpirationMonth let! year = Year.create "expirationYear" cmd.ExpirationYear return { Card.CardNumber = number Name = name HolderId = cmd.UserId Expiration = month,year AccountDetails = AccountInfo.Default cmd.UserId |> Active } }
Modul lengkap untuk pemetaan dan validasi ada di sini dan modul untuk memetakan ke model ada di sini .
Pada titik ini kami memiliki implementasi untuk semua logika bisnis, pemetaan, validasi, dan sebagainya, dan sejauh ini semuanya benar-benar terisolasi dari dunia nyata: semuanya ditulis dalam fungsi murni sepenuhnya. Sekarang Anda mungkin bertanya-tanya, bagaimana tepatnya kita akan memanfaatkan ini? Karena kita memang harus berinteraksi dengan dunia luar. Lebih dari itu, selama eksekusi alur kerja kita harus membuat beberapa keputusan berdasarkan hasil dari interaksi dunia nyata itu. Jadi pertanyaannya adalah bagaimana kita mengumpulkan semua ini? Dalam OOP mereka menggunakan wadah IoC untuk menangani itu, tetapi di sini kita tidak dapat melakukan itu, karena kita bahkan tidak memiliki objek, kita memiliki fungsi statis.
Kita akan menggunakan Interpreter pattern
untuk itu! Agak sulit, sebagian besar karena tidak dikenal, tetapi saya akan melakukan yang terbaik untuk menjelaskan pola ini. Pertama, mari kita bicara tentang komposisi fungsi. Misalnya kita memiliki fungsi int -> string
. Ini berarti bahwa fungsi mengharapkan int
sebagai parameter dan mengembalikan string. Sekarang katakanlah kita memiliki string -> char
fungsi lain string -> char
. Pada titik ini kita dapat mengaitkannya, mis. Jalankan yang pertama, ambil outputnya dan beri makan ke fungsi kedua, dan bahkan ada operator untuk itu: >>
. Begini cara kerjanya:
let intToString (i: int) = i.ToString() let firstCharOrSpace (s: string) = match s with | (null| "") -> ' ' | s -> s.[0] let firstDigitAsChar = intToString >> firstCharOrSpace //
Namun kami tidak dapat menggunakan perangkaian sederhana dalam beberapa skenario, misalnya mengaktifkan kartu. Berikut urutan tindakan:
- memvalidasi nomor kartu input. Jika itu valid, maka
- coba dapatkan kartu dengan nomor ini. Jika ada
- aktifkan itu.
- simpan hasil. Jika tidak apa-apa maka
- memetakan untuk memodelkan dan kembali.
Dua langkah pertama memiliki itu If it's ok then...
Itulah alasan mengapa rantai langsung tidak bekerja.
Kami cukup menyuntikkan sebagai parameter fungsi-fungsi tersebut, seperti ini:
let activateCard getCardAsync saveCardAsync cardNumber = ...
Tapi ada masalah tertentu dengan itu. Pertama, jumlah dependensi dapat tumbuh besar dan fungsi tanda tangan akan terlihat jelek. Kedua, kita terikat dengan efek khusus di sini: kita harus memilih apakah itu Task
atau Async
atau sekadar panggilan sinkronisasi. Ketiga, mudah untuk mengacaukan segalanya ketika Anda memiliki banyak fungsi untuk dilewati: mis. replaceUserAsync
dan replaceUserAsync
memiliki tanda tangan yang sama tetapi efek yang berbeda, jadi ketika Anda harus melewatinya ratusan kali Anda dapat membuat kesalahan dengan gejala yang sangat aneh. Karena alasan itulah kami mencari penerjemah.
Idenya adalah kita membagi kode komposisi kita menjadi 2 bagian: pohon eksekusi dan juru bahasa untuk pohon itu. Setiap node di pohon ini adalah tempat untuk fungsi dengan efek yang ingin kita suntikkan, seperti getUserFromDatabase
. Node-node tersebut didefinisikan berdasarkan nama, mis. getCard
, tipe parameter input, mis. CardNumber
dan tipe return, mis. Card option
. Kami tidak menentukan di sini Task
atau Async
, itu bukan bagian dari pohon, itu adalah bagian dari juru bahasa . Setiap tepi pohon ini adalah serangkaian transformasi murni, seperti eksekusi validasi atau fungsi logika bisnis. Tepi juga memiliki beberapa input, misalnya nomor kartu string mentah, kemudian ada validasi, yang dapat memberi kita kesalahan atau nomor kartu yang valid. Jika ada kesalahan, kita akan memotong tepi itu, jika tidak, itu akan membawa kita ke simpul berikutnya: getCard
. Jika simpul ini akan mengembalikan Some card
, kita dapat melanjutkan ke tepi berikutnya, yang akan menjadi aktivasi, dan seterusnya.
Untuk setiap skenario seperti topUp
atau topUp
atau topUp
kita akan membangun pohon terpisah. Ketika pohon-pohon itu dibangun, simpul mereka agak kosong, mereka tidak memiliki fungsi nyata di dalamnya, mereka memiliki tempat untuk fungsi-fungsi itu. Tujuan penerjemah adalah mengisi simpul-simpul itu, sesederhana itu. Penerjemah tahu efek yang kita gunakan, mis. Task
, dan ia tahu fungsi mana yang sebenarnya dimasukkan ke dalam simpul yang diberikan. Ketika mengunjungi sebuah node, ia mengeksekusi fungsi nyata yang sesuai, menunggu dalam kasus Task
atau Async
, dan meneruskan hasilnya ke tepi berikutnya. Tepi itu dapat mengarah ke simpul lain, dan kemudian itu adalah pekerjaan untuk penerjemah lagi, sampai penerjemah ini mencapai titik berhenti, bagian bawah rekursi kita, di mana kita hanya mengembalikan hasil dari seluruh eksekusi pohon kita.
Seluruh pohon akan diwakili dengan serikat terdiskriminasi, dan sebuah simpul akan terlihat seperti ini:
type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) //
Itu akan selalu menjadi tuple, di mana elemen pertama adalah input untuk ketergantungan Anda, dan elemen terakhir adalah fungsi , yang menerima hasil dari dependensi itu. "Ruang" antara elemen-elemen tuple adalah tempat ketergantungan Anda akan cocok, seperti dalam contoh komposisi, di mana Anda memiliki fungsi 'a -> 'b
, 'c -> 'd
dan Anda perlu meletakkan yang lain 'b -> 'c
di antara untuk menghubungkan mereka.
Karena kita berada di dalam konteks terbatas kita, kita seharusnya tidak memiliki terlalu banyak dependensi, dan jika kita lakukan - mungkin inilah saatnya untuk membagi konteks kita menjadi yang lebih kecil.
Begini tampilannya, sumber lengkap ada di sini :
type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) | GetCardWithAccountInfo of CardNumber * ((Card*AccountInfo) option -> Program<'a>) | CreateCard of (Card*AccountInfo) * (Result<unit, DataRelatedError> -> Program<'a>) | ReplaceCard of Card * (Result<unit, DataRelatedError> -> Program<'a>) | GetUser of UserId * (User option -> Program<'a>) | CreateUser of UserInfo * (Result<unit, DataRelatedError> -> Program<'a>) | GetBalanceOperations of (CardNumber * DateTimeOffset * DateTimeOffset) * (BalanceOperation list -> Program<'a>) | SaveBalanceOperation of BalanceOperation * (Result<unit, DataRelatedError> -> Program<'a>) | Stop of 'a (**) let rec bind f instruction = match instruction with | GetCard (x, next) -> GetCard (x, (next >> bind f)) | GetCardWithAccountInfo (x, next) -> GetCardWithAccountInfo (x, (next >> bind f)) | CreateCard (x, next) -> CreateCard (x, (next >> bind f)) | ReplaceCard (x, next) -> ReplaceCard (x, (next >> bind f)) | GetUser (x, next) -> GetUser (x,(next >> bind f)) | CreateUser (x, next) -> CreateUser (x,(next >> bind f)) | GetBalanceOperations (x, next) -> GetBalanceOperations (x,(next >> bind f)) | SaveBalanceOperation (x, next) -> SaveBalanceOperation (x,(next >> bind f)) | Stop x -> fx (**) let stop x = Stop x let getCardByNumber number = GetCard (number, stop) let getCardWithAccountInfo number = GetCardWithAccountInfo (number, stop) let createNewCard (card, acc) = CreateCard ((card, acc), stop) let replaceCard card = ReplaceCard (card, stop) let getUserById id = GetUser (id, stop) let createNewUser user = CreateUser (user, stop) let getBalanceOperations (number, fromDate, toDate) = GetBalanceOperations ((number, fromDate, toDate), stop) let saveBalanceOperation op = SaveBalanceOperation (op, stop)
With a help of computation expressions , we now have a very easy way to build our workflows without having to care about implementation of real-world interactions. We do that in CardWorkflow module :
(**) let processPayment (currentDate: DateTimeOffset, payment) = program { (**) let! cmd = validateProcessPaymentCommand payment |> expectValidationError let! card = tryGetCard cmd.CardNumber let today = currentDate.Date |> DateTimeOffset let tomorrow = currentDate.Date.AddDays 1. |> DateTimeOffset let! operations = getBalanceOperations (cmd.CardNumber, today, tomorrow) let spentToday = BalanceOperation.spentAtDate currentDate cmd.CardNumber operations let! (card, op) = CardActions.processPayment currentDate spentToday card cmd.PaymentAmount |> expectOperationNotAllowedError do! saveBalanceOperation op |> expectDataRelatedErrorProgram do! replaceCard card |> expectDataRelatedErrorProgram return card |> toCardInfoModel |> Ok }
This module is the last thing we need to implement in business layer. Also, I've done some refactoring: I moved errors and common types to Common project . About time we moved on to implementing data access layer.
Data access layer
The design of entities in this layer may depend on our database or framework we use to interact with it. Therefore domain layer doesn't know anything about these entities, which means we have to take care of mapping to and from domain models in here. Which is quite convenient for consumers of our DAL API. For this application I've chosen MongoDB, not because it's a best choice for this kind of task, but because there're many examples of using SQL DBs already and I wanted to add something different. We are gonna use C# driver.
For the most part it's gonna be pretty strait forward, the only tricky moment is with Card
. When it's active it has an AccountInfo
inside, when it's not it doesn't. So we have to split it in two documents: CardEntity
and CardAccountInfoEntity
, so that deactivating card doesn't erase information about balance and daily limit.
Other than that we just gonna use primitive types instead of discriminated unions and types with built-in validation.
There're also few things we need to take care of, since we are using C# library:
- Convert
null
s to Option<'a>
- Catch expected exceptions and convert them to our errors and wrap it in
Result<_,_>
We start with CardDomainEntities module , where we define our entities:
[<CLIMutable>] type CardEntity = { [<BsonId>] CardNumber: string Name: string IsActive: bool ExpirationMonth: uint16 ExpirationYear: uint16 UserId: UserId } with //
Those fields EntityId
and IdComparer
we are gonna use with a help of SRTP . We'll define functions that will retrieve them from any type that has those fields define, without forcing every entity to implement some interface:
let inline (|HasEntityId|) x = fun () -> (^a : (member EntityId: string) x) let inline entityId (HasEntityId f) = f() let inline (|HasIdComparer|) x = fun () -> (^a : (member IdComparer: Quotations.Expr<Func< ^a, bool>>) x) //
As for null
and Option
thing, since we use record types, F# compiler doesn't allow using null
value, neither for assigning nor for comparison. At the same time record types are just another CLR types, so technically we can and will get a null
value, thanks to C# and design of this library. We can solve this in 2 ways: use AllowNullLiteral
attribute, or use Unchecked.defaultof<'a>
. I went for the second choice since this null
situation should be localized as much as possible:
let isNullUnsafe (arg: 'a when 'a: not struct) = arg = Unchecked.defaultof<'a> //
In order to deal with expected exception for duplicate key, we use Active Patterns again:
//
After mapping is implemented we have everything we need to assemble API for our data access layer , which looks like this:
//
The last moment I mention is when we do mapping Entity -> Domain
, we have to instantiate types with built-in validation, so there can be validation errors. In this case we won't use Result<_,_>
because if we've got invalid data in DB, it's a bug, not something we expect. So we just throw an exception. Other than that nothing really interesting is happening in here. The full source code of data access layer you'll find here .
Composition, logging and all the rest
As you remember, we're not gonna use DI framework, we went for interpreter pattern. If you want to know why, here's some reasons:
- IoC container operates in runtime. So until you run your program you can't know that all the dependencies are satisfied.
- It's a powerful tool which is very easy to abuse: you can do property injection, use lazy dependencies, and sometimes even some business logic can find it's way in dependency registering/resolving (yeah, I've witnessed it). All of that makes code maintaining extremely hard.
That means we need a place for that functionality. We could place it on a top level in our Web Api, but in my opinion it's not a best choice: right now we are dealing with only 1 bounded context, but if there's more, this global place with all the interpreters for each context will become cumbersome. Besides, there's single responsibility rule, and web api project should be responsible for web, right? So we create CardManagement.Infrastructure project .
Here we will do several things:
- Composing our functionality
- App configuration
- Logging
If we had more than 1 context, app configuration and log configuration should be moved to global infrastructure project, and the only thing happening in this project would be assembling API for our bounded context, but in our case this separation is not necessary.
Let's get down to composition. We've built execution trees in our domain layer, now we have to interpret them. Every node in that tree represents some dependency call, in our case a call to database. If we had a need to interact with 3rd party api, that would be in here also. So our interpreter has to know how to handle every node in that tree, which is verified in compile time, thanks to <TreatWarningsAsErrors>
setting. Here's what it looks like:
(**) let rec private interpretCardProgram mongoDb prog = match prog with | GetCard (cardNumber, next) -> cardNumber |> getCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetCardWithAccountInfo (number, next) -> number |> getCardWithAccInfoAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | CreateCard ((card,acc), next) -> (card, acc) |> createCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | ReplaceCard (card, next) -> card |> replaceCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetUser (id, next) -> getUserAsync mongoDb id |> bindAsync (next >> interpretCardProgram mongoDb) | CreateUser (user, next) -> user |> createUserAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetBalanceOperations (request, next) -> getBalanceOperationsAsync mongoDb request |> bindAsync (next >> interpretCardProgram mongoDb) | SaveBalanceOperation (op, next) -> saveBalanceOperationAsync mongoDb op |> bindAsync (next >> interpretCardProgram mongoDb) | Stop a -> async.Return a let interpret prog = try let interpret = interpretCardProgram (getMongoDb()) interpret prog with | failure -> Bug failure |> Error |> async.Return
Note that this interpreter is the place where we have this async
thing. We can do another interpreter with Task
or just a plain sync version of it. Now you're probably wondering, how we can cover this with unit-test, since familiar mock libraries ain't gonna help us. Well, it's easy: you have to make another interpreter. Here's what it can look like:
type SaveResult = Result<unit, DataRelatedError> type TestInterpreterConfig = { GetCard: Card option GetCardWithAccountInfo: (Card*AccountInfo) option CreateCard: SaveResult ReplaceCard: SaveResult GetUser: User option CreateUser: SaveResult GetBalanceOperations: BalanceOperation list SaveBalanceOperation: SaveResult } let defaultConfig = { GetCard = Some card GetUser = Some user GetCardWithAccountInfo = (card, accountInfo) |> Some CreateCard = Ok() GetBalanceOperations = balanceOperations SaveBalanceOperation = Ok() ReplaceCard = Ok() CreateUser = Ok() } let testInject a = fun _ -> a let rec interpretCardProgram config (prog: Program<'a>) = match prog with | GetCard (cardNumber, next) -> cardNumber |> testInject config.GetCard |> (next >> interpretCardProgram config) | GetCardWithAccountInfo (number, next) -> number |> testInject config.GetCardWithAccountInfo |> (next >> interpretCardProgram config) | CreateCard ((card,acc), next) -> (card, acc) |> testInject config.CreateCard |> (next >> interpretCardProgram config) | ReplaceCard (card, next) -> card |> testInject config.ReplaceCard |> (next >> interpretCardProgram config) | GetUser (id, next) -> id |> testInject config.GetUser |> (next >> interpretCardProgram config) | CreateUser (user, next) -> user |> testInject config.CreateUser |> (next >> interpretCardProgram config) | GetBalanceOperations (request, next) -> testInject config.GetBalanceOperations request |> (next >> interpretCardProgram config) | SaveBalanceOperation (op, next) -> testInject config.SaveBalanceOperation op |> (next >> interpretCardProgram config) | Stop a -> a
We've created TestInterpreterConfig
which holds desired results of every operation we want to inject. You can easily change that config for every given test and then just run interpreter. This interpreter is sync, since there's no reason to bother with Task
or Async
.
There's nothing really tricky about the logging, but you can find it in this module . The approach is that we wrap the function in logging: we log function name, parameters and log result. If result is ok, it's info, if error it's a warning and if it's a Bug
then it's an error. That's pretty much it.
One last thing is to make a facade, since we don't want to expose raw interpreter calls. Here's the whole thing:
let createUser arg = arg |> (CardWorkflow.createUser >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createUser") let createCard arg = arg |> (CardWorkflow.createCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createCard") let activateCard arg = arg |> (CardWorkflow.activateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.activateCard") let deactivateCard arg = arg |> (CardWorkflow.deactivateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.deactivateCard") let processPayment arg = arg |> (CardWorkflow.processPayment >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.processPayment") let topUp arg = arg |> (CardWorkflow.topUp >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.topUp") let setDailyLimit arg = arg |> (CardWorkflow.setDailyLimit >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.setDailyLimit") let getCard arg = arg |> (CardWorkflow.getCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.getCard") let getUser arg = arg |> (CardWorkflow.getUser >> CardProgramInterpreter.interpretSimple |> logifyResultAsync "CardApi.getUser")
All the dependencies here are injected, logging is taken care of, no exceptions is thrown — that's it. For web api I used Giraffe framework. Web project is here .
Kesimpulan
We have built an application with validation, error handling, logging, business logic — all those things you usually have in your application. The difference is this code is way more durable and easy to refactor. Note that we haven't used reflection or code generation, no exceptions, but still our code isn't verbose. It's easy to read, easy to understand and hard to break. As soon as you add another field in your model, or another case in one of our union types, the code won't compile until you update every usage. Sure it doesn't mean you're totally safe or that you don't need any kind of testing at all, it just means that you're gonna have fewer problems when you develope new features or do some refactoring. The development process will be both cheaper and more interesting, because this tool allows you to focus on your domain and business tasks, instead of drugging focus on keeping an eye out that nothing is broken.
Another thing: I don't claim that OOP is completely useless and we don't need it, that's not true. I'm saying that we don't need it for solving every single task we have, and that a big portion of our tasks can be better solved with FP. And truth is, as always, in balance: we can't solve everything efficiently with only one tool, so a good programming language should have a decent support of both FP and OOP. And, unfortunately, a lot of most popular languages today have only lambdas and async programming from functional world.