Here’s how I designed my structure to share portable code between client and server while keeping database I/O on the server side, with a developer-friendly API which makes impossible states unrepresentable.
I’ll be exploring with the example concept of “users” with some fields. The simplest, unsafe representation would be:
type user =
{ id : int option
; password : string
; email : string
; name : string
; company : string
; url : string
}
This allows plenty of impossible states such as invalid e-mail addresses and non-existent database Ids.
Custom Types For Custom Validation
At minimum, I want to enforce some validation by wrapping types which are too broad, forcing the use of creator functions. Let’s also make optional the fields which are not required for a user to be valid, to avoid giving special meaning to some values like ""
.
(* user.ml *)
type t =
{ id : id option
; password : Password.t option
; email : Email.t
; name : String255.t
; company : String255.t option
; url : String255.t option
}
(* No creators for id: always comes from DB. *)
and type id = private int
We now have a record which is still entirely readable (i.e. String255.t
can still be coerced into a string
), but for which field values must necessarily be created by validator functions. See my post about how I create such wrapper types.
More Constraints, More Helper Functions
Let’s make things more interesting:
- make creation and modification easier with
create
andupdate
functions; - require that a URL be only supplied when there’s a company name;
- enforce constraints at compile-time anywhere possible.
module User : sig
type t =
{ id : id option
; password : Password.t option
; email : Email.t
; name : String255.t
; company : company option
}
and id = private int
and company =
{ name : String255.t
; url : String255.t option
}
val create
: ?password:string
-> ?company_name:string
-> ?company_url:string
-> email:string
-> name:string
-> unit
-> (t, string) result
val update
: ?password:string
-> ?email:string
-> ?name:string
-> ?company_name:string
-> ?company_url:string
-> t
-> (t, string) result
end
We now enforce all our new constraints; any user created outside of our module is still guaranteed to be valid. Functions create
and update
offer handy shortcuts to all the relevant fields’ type-specific constructors.
Routine operations like changing a user’s name and e-mail address become simpler, hiding validation boilerplate:
(* By hand *)
let updated_user =
{ old_user with
name = String255.of_string_exn new_name
; email = Email.of_string_exn new_email }
in
(* Using update *)
let updated_user = User.update_exn ~name:new_name ~email:new_email old_user in
Database I/O And Serialization
I need to use User
in client applications without a database back-end, and thus without query building code nor dependency on database drivers. I would obtain instances of User.t
from some network exchange of serialized information. (JSON, Google Protocol Buffers, Apache Thrift, Bin_prot, etc.) My current plan is to use ppx_deriving_yojson
on both the client side (native and Js_of_ocaml) and server side, and to only worry about the faster binary formats if/when I’ll see a measurable bottleneck with JSON.
For server-side things like database I/O, I can create a second module which extends the general one with the additional functionality. For example, User_db
could be an extension of User
including all its types and functions. Our client library would alias User
as itself while our server library would alias User_db
to the same name, so that as much code as possible remains portable between programs on the client and server sides.
(* user.mli *)
type t = { ... } [@@deriving yojson]
and id = private int [@@deriving yojson]
val id_of_int_unsafe : manually_validated_id:int -> id
val create ...
val update ...
(* user.ml *)
type t = { ... } [@@deriving yojson]
and id = int [@@deriving yojson]
let id_of_int_unsafe ~manually_validated_id:i = i
let create ...
let update ...
(* user_db.mli *)
include module type of User
val get_from_db : int -> t
(* user_db.ml *)
include User
let get_from_db id =
let id = User.id_of_int_unsafe ~manually_validated_id=id in
...
(* client_library.ml *)
module User = User
(* server_library.ml *)
module User = User_db
Notice I had no choice but to introduce a means to create a User.id
from outside User
, so that extension modules like User_db
may create proper User.t
with IDs (which User.create
can not, by design). Similarly to how some standard libraries designed unsafe functions, I choose to use an explicit and fairly verbose name and labeled argument to make it clear that creating a User.id
comes with its load of responsibilities.
Presumably, I will also create a User_rpc
module extension of User
for use with browsers, with an API equivalent to, or possibly even identical to, User_db
making HTTP and/or WebSocket calls in some fashion to get JSON versions of User.t
over the wire.