Why Athena
Creating "good" Software#
When creating an application, actually writing the code is often the easiest part. Designing a system that will be readable, maintainable, testable, and extensible on the other hand is a much more challenging task. The features of the Athena Framework encourage creating such software. However it does not do much good without also understanding the why behind the way it is designed the way it is. Let's take a moment to explore how the features mentioned in the introduction can lead to "good" software design.
Warning
As with anything in the software world, "good" software is subjective. The design decision/suggestions on this page are intended to be educational and provide "best practices" guidelines. They are NOT the only way to use the framework nor prescriptive. Do whatever makes the most sense for your project.
SOLID Principles#
The SOLID principles are applicable to any Object Oriented Programming (OOP) language. They play a big part in the underlying architecture of the Athena Framework, and the overall ecosystem of Athena itself. There are plenty of resources online to learn more about all of the principles, but this section will focus on that of the Dependency Inversion and Single Responsibility principles and how an Inversion of Control (IoC) service container orchestrates it all via dependency injection.
Single Responsibility#
Just as the name implies, this principle suggests that each type should have only a single primary purpose. Having types with specialized focuses has various benefits including:
- Easier to test
- Less coupling due to lower amount of dependencies it requires
- Easier to read and search for
A more concrete example of this could be say there is a class representing an article:
class Article
property title : String
property author : String
property body : String
def initialize(@title : String, @author : String, @body : String); end
def includes_word?(word : String) : Bool
@body.includes? word
end
# ...
end
This type currently only has a single purpose which is representing an article. It also exposes some helper methods related to querying information about each article which are also valid under this principle. However, if a new method was added to persist the article to some location, the class would now no longer have just one purpose, thus violating the single responsibility principle.
In this example, it would be better to add another type, say ArticlePersister
to handle this functionality:
@[ADI::Register]
class ArticlePersister
def persist(article : Article) : Nil
# ...
end
end
Services#
A sharp eye will notice this type was created with the ADI::Register annotation applied to it. This registers the type as a service, which is essentially just a useful object that could be used by other services. Not all types are services though, such as the Article
type. This is because it only stores data within the domain of the application and does not provide any useful functionality on its own. More on this topic in the dependency injection section.
Dependency Inversion#
This principle states that code should "Depend upon abstractions, [not] concretions." In other words, services should depend upon interfaces instead of concrete types. This not only makes the depending services more flexible since different implementations of the interface could be used, but also makes testing easier since mock implementations could also be used. In Crystal, an interface is nothing more than a module with abstract defs that can be included within another type in order to force the including type to define its methods.The example from the previous principle can be used to demonstrate.
The ArticlePersister
can be used to persist an article. For example say there is another service in which an article should be persisted. This could be a controller action, a console command, some sort of async consumer, etc. The easiest way to handle persisting of the article would be to do something like:
@[ADI::Register]
class MyService
def execute
article = # ...
persister = ArticlePersister.new
persister.persist article
end
end
However this has some problems since it tightly couples MyService
to the ArticlePersister
service. Not super ideal.
def initialize
@persister = ArticlePersister.new
end
Moving the persister into an instance variable created within the constructor is a bit better but also suffers from the same issue. The ideal solution here would be to provide an ArticlePersister
instance to MyService
when it is instantiated:
def initialize(
@persister : ArticlePersister
); end
The same behavior as before can also be retained, even when using this new pattern. This will use the provided instance, or fall back on a default implementation if no custom instance is provided:
def initialize(
persister : ArticlePersister? = nil
)
@persister = persister || ArticlePersister.new
end
Both of these latter two examples remove the tight coupling between the two services. However there is still one thing that is less than ideal. It should be possible to persist an article in multiple places. Meaning it needs to allow for more than one implementation of ArticlePersister
that handles different locations, such as one for a database and another for the local filesystem. The best way to handle this would be to create an interface module for this type:
module ArticlePersisterInterface
abstract def persist(article : Article) : Nil
end
From here the constructor of MyService
should be updated to use it:
def initialize(
@persister : ArticlePersisterInterface
); end
Also being sure to include the interface in our service:
@[ADI::Register]
class ArticlePersister
include ArticlePersisterInterface
def persist(article : Article) : Nil
# ...
end
end
While this is a bit of extra boilerplate, it is an incredibly powerful pattern. It enables MyService
to persist an article to anywhere, depending on what implementation instance it is instantiated with. The same pattern can be extended to make testing the service much easier. A mock implementation of ArticlePersisterInterface
can be used to assert MyService
calls with the proper arguments without testing more than is required.
Flexibility#
Athena Framework is very flexible in that it is able to support both simple and complex use cases by adapting to the needs of the application without getting in the way of customizations the user wants to make. This is accomplished by providing all the components to the user, but not requiring they be used. If an application does not need to validate anything, the Athena::Validator component can just be ignored. But if the need ever arises it is there and well integrated into the framework.
Dependency Injection#
Athena Framework includes an IoC Service Container that manages services automatically. Any service, or a useful type, annotated with ADI::Register, can be used in another service by defining a constructor typed to the desired service. For example:
require "athena"
# Register an example service that provides a name string.
@[ADI::Register]
class NameProvider
def name : String
"World"
end
end
# Register another service that depends on the previous service and provides a value.
@[ADI::Register]
class ValueProvider
def initialize(@name_provider : NameProvider); end
def value : String
"Hello " + @name_provider.name
end
end
# Register a service controller that depends upon the ValueProvider.
@[ADI::Register]
class ExampleController < ATH::Controller
def initialize(@value_provider : ValueProvider); end
@[ARTA::Get("/")]
def get_value : String
@value_provider.value
end
end
ATH.run
# GET / # => "Hello World"
It is worth noting again that while dependency injection is a big part of the framework, it is not necessarily required to fully understand it in order to use the framework, but like the other components, it is there if needed. Checkout ADI::Register, especially the aliasing services section.
Athena Framework is almost fully overridable/customizable in part since it embraces dependency injection. Want to globally customize how errors are rendered? Create a service implementing ATH::ErrorRendererInterface and make it an alias of the interface:
@[ADI::Register(alias: ATH::ErrorRendererInterface)]
class MyCustomErrorRenderer
include Athena::Framework::ErrorRendererInterface
# :inherit:
def render(exception : ::Exception) : ATH::Response
ATH::Response.new ...
end
end
Athena Framework will pick this up and use it instead of the built in version without any other required configuration changes. The same concept applies to many different features within the framework that have their own interface/default implementation.
Middleware#
Unlike other frameworks, Athena Framework leverages event based middleware instead of a pipeline based approach. This enables a lot of flexibility in that there is nothing extra that needs to be done to register the listener other than creating a service for it:
@[ADI::Register]
class CustomListener
@[AEDA::AsEventListener]
def on_response(event : ATH::Events::Response) : Nil
event.response.headers["FOO"] = "BAR"
end
end
Similarly, the framework itself is implemented using the same features available to the users. Thus it is very easy to run specific listeners before/after the built-in ones if so desired.
Tip
Check out the debug:event-dispatcher
command for an easy way to see all the listeners and the order in which they are executed.
Annotations#
One of the more unique aspects of Athena Framework, and the Athena ecosystem, is its use of annotations as a means of configuring the framework. While not everyone may like their syntax, the benefits they provide are undeniable. The main benefit being they keep the code close to where it is used. The route of a controller action is declared directly above the method that handles it and not in some other file. Metadata associated with a specific service/route is also right there with the type itself.
Point of Extension#
A common way to do certain things in other frameworks is the use of macro DSLs specific to each framework. While it can work well, it makes it harder to expand upon/customize. Given annotations are a core Crystal language construct, there nothing special needed to access the annotations themselves. This can be especially useful for third party code to have a tighter integration while also being totally agnostic of what framework the code is even used in.
User Defined Annotations#
One of the most powerful features Athena Framework offers is that of custom user defined annotations which provide almost an infinite amount of use cases. These annotations could be applied to controller classes and/or controller actions to expose additional information to other services, such as event listeners or ATHR::Interfaces to customize their behavior on a case by case basis.
require "athena"
# Define our configuration annotation with an optional `name` argument.
# A default value can also be provided, or made not nilable to be considered required.
ADI.configuration_annotation MyAnnotation, name : String? = nil
# Define and register our listener that will do something based on our annotation.
@[ADI::Register]
class MyAnnotationListener
@[AEDA::AsEventListener]
def on_view(event : ATH::Events::View) : Nil
# Represents all custom annotations applied to the current ATH::Action.
ann_configs = event.request.action.annotation_configurations
# Check if this action has the annotation
unless ann_configs.has? MyAnnotation
# Do something based on presence/absence of it.
# Would be executed for `ExampleController#one` since it does not have the annotation applied.
end
my_ann = ann_configs[MyAnnotation]
# Access data off the annotation.
if my_ann.name == "Fred"
# Do something if the provided name is/is not some value.
# Would be executed for `ExampleController#two` since it has the annotation applied, and name value equal to "Fred".
end
end
end
class ExampleController < ATH::Controller
@[ARTA::Get("one")]
def one : Int32
1
end
@[ARTA::Get("two")]
@[MyAnnotation(name: "Fred")]
def two : Int32
2
end
end
ATH.run
Primary Use Cases#
While the components that make up Athena Framework can be used within a wide range of applications, the framework itself is best suited for a few main types, including HTTP REST APIs, CLI Applications, or a combination of both. Since both types of entry points leverage dependency injection, services can be used in both contexts, allowing the majority of code to be reused.
HTTP REST API#
At its core, Athena Framework is a MVC web application framework. It can be used to serve any kind of content, but best lends itself to creating RESTful JSON APIs due to the features explained in the previous section, as well as due its native JSON support:
- Objects returned from the controller are JSON serialized by default
- Native support for both ASR::Serializable and JSON::Serializable
- Native support for DTOs to deserialize and validate, see ATHR::RequestBody
require "athena"
struct UserCreate
include AVD::Validatable
include JSON::Serializable
@[Assert::NotBlank]
@[Assert::Email(:html5)]
getter email : String
# ...
end
class UserController < ATH::Controller
@[ARTA::Post("/user")]
@[ATHA::View(status: :created)]
def new_user(
@[ATHR::RequestBody::Extract]
user_create : UserCreate
) : UserCreate
# Use the provided UserCreate instance to create an actual User DB record.
# For purposes of this example, just return the instance.
user_create
end
end
ATH.run
# POST /user body: {"email":"athenaframework.org"} # =>
# {
# "code": 422,
# "message": "Validation failed",
# "errors": [
# {
# "property": "email",
# "message": "This value is not a valid email address.",
# "code": "ad9d877d-9ad1-4dd7-b77b-e419934e5910"
# }
# ]
# }
# POST /user body: {"email":"[email protected]"} # => {"email":"[email protected]"}
CLI Applications#
Athena Framework can also be used to build CLI based applications. These could either be used directly by the end user, used for internal administrative tasks, or invoked on a schedule via cron
or something similar.
@[ACONA::AsCommand("app:create-user")]
@[ADI::Register]
class CreateUserCommand < ACON::Command
protected def configure : Nil
# ...
end
protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status
# Implement all the business logic here.
# Indicates the command executed successfully.
Status::SUCCESS
end
end
$ ./bin/console
Athena 0.18.0
Usage:
command [options] [arguments]
Options:
-h, --help Display help for the given command. When no command is given display help for the list command
-q, --quiet Do not output any message
-V, --version Display this application version
--ansi|--no-ansi Force (or disable --no-ansi) ANSI output
-n, --no-interaction Do not ask any interactive question
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
Available commands:
help Display help for a command
list List commands
app
app:create-user
debug
debug:event-dispatcher Display configured listeners for an application
debug:router Display current routes for an application
debug:router:match Simulate a path match to see which route, if any, would handle it
Checkout the Console component for more information.