diff --git a/lib/hanami/action.rb b/lib/hanami/action.rb
index 4e1015cb..ef4929a9 100644
--- a/lib/hanami/action.rb
+++ b/lib/hanami/action.rb
@@ -39,7 +39,7 @@ def self.gem_loader
         loader.ignore(
           "#{root}/hanami-controller.rb",
           "#{root}/hanami/controller/version.rb",
-          "#{root}/hanami/action/{constants,errors,params,validatable}.rb"
+          "#{root}/hanami/action/{constants,errors,params,contract,validatable}.rb"
         )
         loader.inflector.inflect("csrf_protection" => "CSRFProtection")
       end
@@ -138,6 +138,19 @@ def self.params(_klass = nil)
             "To use `params`, please add 'hanami/validations' gem to your Gemfile"
     end
 
+    # Placeholder implementation for contract class method
+    #
+    # Raises a developer friendly error to include `hanami/validations`.
+    #
+    # @raise [NoMethodError]
+    #
+    # @api public
+    # @since 2.2.0
+    def self.contract
+      raise NoMethodError,
+            "To use `contract`, please add 'hanami/validations' gem to your Gemfile"
+    end
+
     # @overload self.append_before(*callbacks, &block)
     #   Define a callback for an Action.
     #   The callback will be executed **before** the action is called, in the
@@ -305,8 +318,8 @@ def call(env)
       response = nil
 
       halted = catch :halt do
-        params   = self.class.params_class.new(env)
-        request  = build_request(
+        params = self.class.params_class.new(env)
+        request = build_request(
           env: env,
           params: params,
           session_enabled: session_enabled?
diff --git a/lib/hanami/action/base_params.rb b/lib/hanami/action/base_params.rb
index 203d7173..3b6999c4 100644
--- a/lib/hanami/action/base_params.rb
+++ b/lib/hanami/action/base_params.rb
@@ -19,18 +19,13 @@ class Action
     # @api private
     # @since 0.7.0
     class BaseParams
+      include Hanami::Action::RequestParams::Base
       # @attr_reader env [Hash] the Rack env
       #
       # @since 0.7.0
       # @api private
       attr_reader :env
 
-      # @attr_reader raw [Hash] the raw params from the request
-      #
-      # @since 0.7.0
-      # @api private
-      attr_reader :raw
-
       # Returns a new frozen params object for the Rack env.
       #
       # @param env [Hash] a Rack env or an hash of params.
@@ -39,23 +34,11 @@ class BaseParams
       # @api private
       def initialize(env)
         @env    = env
-        @raw    = _extract_params
-        @params = Utils::Hash.deep_symbolize(@raw)
+        @input  = Hanami::Action::ParamsExtraction.new(env).call
+        @params = Utils::Hash.deep_symbolize(@input)
         freeze
       end
 
-      # Returns the value for the given params key.
-      #
-      # @param key [Symbol] the key
-      #
-      # @return [Object,nil] the associated value, if found
-      #
-      # @since 0.7.0
-      # @api public
-      def [](key)
-        @params[key]
-      end
-
       # Returns an value associated with the given params key.
       #
       # You can access nested attributes by listing all the keys in the path. This uses the same key
@@ -86,17 +69,6 @@ def [](key)
       #     end
       #   end
       #
-      # @since 0.7.0
-      # @api public
-      def get(*keys)
-        @params.dig(*keys)
-      end
-
-      # This is for compatibility with Hanami::Helpers::FormHelper::Values
-      #
-      # @api private
-      # @since 0.8.0
-      alias_method :dig, :get
 
       # Returns true at all times, providing a common interface with {Params}.
       #
@@ -110,50 +82,8 @@ def valid?
         true
       end
 
-      # Returns a hash of the parsed request params.
-      #
-      # @return [Hash]
-      #
-      # @since 0.7.0
-      # @api public
-      def to_h
-        @params
-      end
-      alias_method :to_hash, :to_h
-
-      # Iterates over the params.
-      #
-      # Calls the given block with each param key-value pair; returns the full hash of params.
-      #
-      # @yieldparam key [Symbol]
-      # @yieldparam value [Object]
-      #
-      # @return [to_h]
-      #
-      # @since 0.7.1
-      # @api public
-      def each(&blk)
-        to_h.each(&blk)
-      end
-
       private
 
-      # @since 0.7.0
-      # @api private
-      def _extract_params
-        result = {}
-
-        if env.key?(Action::RACK_INPUT)
-          result.merge! ::Rack::Request.new(env).params
-          result.merge! _router_params
-        else
-          result.merge! _router_params(env)
-          env[Action::REQUEST_METHOD] ||= Action::DEFAULT_REQUEST_METHOD
-        end
-
-        result
-      end
-
       # @since 0.7.0
       # @api private
       def _router_params(fallback = {})
diff --git a/lib/hanami/action/contract.rb b/lib/hanami/action/contract.rb
new file mode 100644
index 00000000..144119eb
--- /dev/null
+++ b/lib/hanami/action/contract.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module Hanami
+  class Action
+    # A wrapper for defining validation rules using Dry::Validation. This class essentially
+    # wraps a Dry::Validation::Contract and acts as a proxy to actually use Dry gem
+    #
+    # Defined via the `contract` block in an action class.
+    # Although more complex domain-specific validations, or validations concerned with things such as uniqueness
+    # are usually better performed at layers deeper than your HTTP actions, Contract still provides helpful features
+    # that you can use without contravening the advice form above.
+    #
+    # @since 2.2.0
+    class Contract
+      include Hanami::Action::RequestParams::ActionValidations
+      include Hanami::Action::RequestParams::Base
+      # A wrapper for the result of a contract validation
+      # @since 2.2.0
+      # @api private
+      class Result < SimpleDelegator
+        # @since 2.0.0
+        # @api private
+        def to_h
+          __getobj__.to_h
+        end
+
+        # This method is called messages not errors to be consistent with the Hanami::Validations::Result
+        #
+        # @return [Hash] the error messages
+        #
+        # @since 2.0.0
+        # @api private
+        def messages
+          __getobj__.errors.to_h
+        end
+      end
+
+      # Define a contract for the given action
+      #
+      # @param blk [Proc] the block to define the contract, including [Params] as a contract schema and connected rules
+      #
+      # @since 2.2.0
+      # @api private
+      #
+      # @example
+      # class Create < Hanami::Action
+      #   contract do
+      #     params do
+      #       required(:birth_date).value(:date)
+      #     end
+      #     rule(:birth_date) do
+      #       key.failure('you must be 18 years or older to register') if value > Date.today - 18.years
+      #     end
+      #   end
+      #
+      #   def handle(req, *)
+      #     halt 400 unless req.contract.call.errors.empty?
+      #     # ...
+      #   end
+      # end
+      def self.contract(&blk)
+        @_validator = Dry::Validation::Contract.build(&blk)
+      end
+
+      # @since 2.2.0
+      # @api private
+      class << self
+        attr_reader :_validator
+      end
+
+      private
+
+      # @since 2.2.0
+      def validate
+        Result.new(
+          self.class._validator.call(@input)
+        )
+      end
+    end
+  end
+end
diff --git a/lib/hanami/action/params.rb b/lib/hanami/action/params.rb
index 2e1dfe83..360acb30 100644
--- a/lib/hanami/action/params.rb
+++ b/lib/hanami/action/params.rb
@@ -16,97 +16,7 @@ class Action
     # @since 0.1.0
     class Params < BaseParams
       include Hanami::Validations::Form
-
-      # Params errors
-      #
-      # @since 1.1.0
-      class Errors < SimpleDelegator
-        # @since 1.1.0
-        # @api private
-        def initialize(errors = {})
-          super(errors.dup)
-        end
-
-        # Add an error to the param validations
-        #
-        # This has a semantic similar to `Hash#dig` where you use a set of keys
-        # to get a nested value, here you use a set of keys to set a nested
-        # value.
-        #
-        # @param args [Array<Symbol, String>] an array of arguments: the last
-        #   one is the message to add (String), while the beginning of the array
-        #   is made of keys to reach the attribute.
-        #
-        # @raise [ArgumentError] when try to add a message for a key that is
-        #   already filled with incompatible message type.
-        #   This usually happens with nested attributes: if you have a `:book`
-        #   schema and the input doesn't include data for `:book`, the messages
-        #   will be `["is missing"]`. In that case you can't add an error for a
-        #   key nested under `:book`.
-        #
-        # @since 1.1.0
-        #
-        # @example Basic usage
-        #   require "hanami/controller"
-        #
-        #   class MyAction < Hanami::Action
-        #     params do
-        #       required(:book).schema do
-        #         required(:isbn).filled(:str?)
-        #       end
-        #     end
-        #
-        #     def handle(req, res)
-        #       # 1. Don't try to save the record if the params aren't valid
-        #       return unless req.params.valid?
-        #
-        #       BookRepository.new.create(req.params[:book])
-        #     rescue Hanami::Model::UniqueConstraintViolationError
-        #       # 2. Add an error in case the record wasn't unique
-        #       req.params.errors.add(:book, :isbn, "is not unique")
-        #     end
-        #   end
-        #
-        # @example Invalid argument
-        #   require "hanami/controller"
-        #
-        #   class MyAction < Hanami::Action
-        #     params do
-        #       required(:book).schema do
-        #         required(:title).filled(:str?)
-        #       end
-        #     end
-        #
-        #     def handle(req, *)
-        #       puts req.params.to_h   # => {}
-        #       puts req.params.valid? # => false
-        #       puts req.params.error_messages # => ["Book is missing"]
-        #       puts req.params.errors         # => {:book=>["is missing"]}
-        #
-        #       req.params.errors.add(:book, :isbn, "is not unique") # => ArgumentError
-        #     end
-        #   end
-        def add(*args)
-          *keys, key, error = args
-          _nested_attribute(keys, key) << error
-        rescue TypeError
-          raise ArgumentError.new("Can't add #{args.map(&:inspect).join(', ')} to #{inspect}")
-        end
-
-        private
-
-        # @since 1.1.0
-        # @api private
-        def _nested_attribute(keys, key)
-          if keys.empty?
-            self
-          else
-            keys.inject(self) { |result, k| result[k] ||= {} }
-            dig(*keys)
-          end[key] ||= []
-        end
-      end
-
+      include Hanami::Action::RequestParams::ActionValidations
       # This is a Hanami::Validations extension point
       #
       # @since 0.7.0
@@ -147,111 +57,6 @@ def self._base_rules
       def self.params(&blk)
         validations(&blk || -> {})
       end
-
-      # Initialize the params and freeze them.
-      #
-      # @param env [Hash] a Rack env or an hash of params.
-      #
-      # @return [Params]
-      #
-      # @since 0.1.0
-      # @api private
-      def initialize(env)
-        @env = env
-        super(_extract_params)
-        validation = validate
-        @params = validation.to_h
-        @errors = Errors.new(validation.messages)
-        freeze
-      end
-
-      # Returns raw params from Rack env
-      #
-      # @return [Hash]
-      #
-      # @since 0.3.2
-      def raw
-        @input
-      end
-
-      # Returns structured error messages
-      #
-      # @return [Hash]
-      #
-      # @since 0.7.0
-      #
-      # @example
-      #   params.errors
-      #     # => {
-      #            :email=>["is missing", "is in invalid format"],
-      #            :name=>["is missing"],
-      #            :tos=>["is missing"],
-      #            :age=>["is missing"],
-      #            :address=>["is missing"]
-      #          }
-      attr_reader :errors
-
-      # Returns flat collection of full error messages
-      #
-      # @return [Array]
-      #
-      # @since 0.7.0
-      #
-      # @example
-      #   params.error_messages
-      #     # => [
-      #            "Email is missing",
-      #            "Email is in invalid format",
-      #            "Name is missing",
-      #            "Tos is missing",
-      #            "Age is missing",
-      #            "Address is missing"
-      #          ]
-      def error_messages(error_set = errors)
-        error_set.each_with_object([]) do |(key, messages), result|
-          k = Utils::String.titleize(key)
-
-          msgs = if messages.is_a?(::Hash)
-                   error_messages(messages)
-                 else
-                   messages.map { |message| "#{k} #{message}" }
-                 end
-
-          result.concat(msgs)
-        end
-      end
-
-      # Returns true if no validation errors are found,
-      # false otherwise.
-      #
-      # @return [TrueClass, FalseClass]
-      #
-      # @since 0.7.0
-      #
-      # @example
-      #   params.valid? # => true
-      def valid?
-        errors.empty?
-      end
-
-      # Serialize validated params to Hash
-      #
-      # @return [::Hash]
-      #
-      # @since 0.3.0
-      def to_h
-        @params
-      end
-      alias_method :to_hash, :to_h
-
-      # Pattern-matching support
-      #
-      # @return [::Hash]
-      #
-      # @since 2.0.2
-      def deconstruct_keys(*)
-        to_hash
-      end
     end
   end
 end
diff --git a/lib/hanami/action/params_extraction.rb b/lib/hanami/action/params_extraction.rb
new file mode 100644
index 00000000..550fbc7d
--- /dev/null
+++ b/lib/hanami/action/params_extraction.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require "rack/request"
+
+module Hanami
+  class Action
+    # since 2.2.0
+    # @api private
+    class ParamsExtraction
+      def initialize(env)
+        @env = env
+      end
+
+      def call
+        _extract_params
+      end
+
+      private
+
+      attr_reader :env
+
+      def _extract_params
+        result = {}
+
+        if env.key?(Action::RACK_INPUT)
+          result.merge! ::Rack::Request.new(env).params
+          result.merge! _router_params
+        else
+          result.merge! _router_params(env)
+          env[Action::REQUEST_METHOD] ||= Action::DEFAULT_REQUEST_METHOD
+        end
+
+        result
+      end
+
+      def _router_params(fallback = {})
+        env.fetch(ROUTER_PARAMS) do
+          if (session = fallback.delete(Action::RACK_SESSION))
+            fallback[Action::RACK_SESSION] = Utils::Hash.deep_symbolize(session)
+          end
+
+          fallback
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hanami/action/request.rb b/lib/hanami/action/request.rb
index 4c123366..c3318c7b 100644
--- a/lib/hanami/action/request.rb
+++ b/lib/hanami/action/request.rb
@@ -18,10 +18,11 @@ class Action
     class Request < ::Rack::Request
       # Returns the request's params.
       #
-      # For an action with {Validatable} included, this will be a {Params} instance, otherwise a
-      # {BaseParams}.
+      # For an action with {Validatable} included, this will be a {Params} or {Contract} instance
+      # (if using contract block),
+      # otherwise a {BaseParams} when using params block.
       #
-      # @return [BaseParams,Params]
+      # @return [BaseParams,Params, Contract]
       #
       # @since 2.0.0
       # @api public
diff --git a/lib/hanami/action/request_params/action_validations.rb b/lib/hanami/action/request_params/action_validations.rb
new file mode 100644
index 00000000..c0aaea22
--- /dev/null
+++ b/lib/hanami/action/request_params/action_validations.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+module Hanami
+  class Action
+    module RequestParams
+      # Handles different ways to validate request params in an action, that come in from Hanami::Action::Validatable
+      # It is supposed to handle all approaches through a common interface to access params/contract in the same way
+      #
+      # If you use params block or contract block, you can access the params/contract in the same way through
+      # the request object
+      # They will have the same methods available and all. Just keep in mind the differences between schema and contract
+      # since 2.2.0
+      # @api private
+      module ActionValidations
+        # Common errors for both params and contract validations
+        #
+        # @since 2.2.0
+        class Errors < SimpleDelegator
+          # @since 1.1.0
+          # @api private
+          def initialize(errors = {})
+            super(errors.dup)
+          end
+
+          # Add an error to the param validations
+          #
+          # This has a semantic similar to `Hash#dig` where you use a set of keys
+          # to get a nested value, here you use a set of keys to set a nested
+          # value.
+          #
+          # @param args [Array<Symbol, String>] an array of arguments: the last
+          #   one is the message to add (String), while the beginning of the array
+          #   is made of keys to reach the attribute.
+          #
+          # @raise [ArgumentError] when try to add a message for a key that is
+          #   already filled with incompatible message type.
+          #   This usually happens with nested attributes: if you have a `:book`
+          #   schema and the input doesn't include data for `:book`, the messages
+          #   will be `["is missing"]`. In that case you can't add an error for a
+          #   key nested under `:book`.
+          #
+          # @since 1.1.0
+          #
+          # @example Basic usage
+          #   require "hanami/controller"
+          #
+          #   class MyAction < Hanami::Action
+          #     params do
+          #       required(:book).schema do
+          #         required(:isbn).filled(:str?)
+          #       end
+          #     end
+          #
+          #     def handle(req, res)
+          #       # 1. Don't try to save the record if the params aren't valid
+          #       return unless req.params.valid?
+          #
+          #       BookRepository.new.create(req.params[:book])
+          #     rescue Hanami::Model::UniqueConstraintViolationError
+          #       # 2. Add an error in case the record wasn't unique
+          #       req.params.errors.add(:book, :isbn, "is not unique")
+          #     end
+          #   end
+          #
+          # @example Invalid argument
+          #   require "hanami/controller"
+          #
+          #   class MyAction < Hanami::Action
+          #     params do
+          #       required(:book).schema do
+          #         required(:title).filled(:str?)
+          #       end
+          #     end
+          #
+          #     def handle(req, *)
+          #       puts req.params.to_h   # => {}
+          #       puts req.params.valid? # => false
+          #       puts req.params.error_messages # => ["Book is missing"]
+          #       puts req.params.errors         # => {:book=>["is missing"]}
+          #
+          #       req.params.errors.add(:book, :isbn, "is not unique") # => ArgumentError
+          #     end
+          #   end
+          def add(*args)
+            *keys, key, error = args
+            _nested_attribute(keys, key) << error
+          rescue TypeError
+            raise ArgumentError.new("Can't add #{args.map(&:inspect).join(', ')} to #{inspect}")
+          end
+
+          private
+
+          # @since 1.1.0
+          # @api private
+          def _nested_attribute(keys, key)
+            if keys.empty?
+              self
+            else
+              keys.inject(self) { |result, k| result[k] ||= {} }
+              dig(*keys)
+            end[key] ||= []
+          end
+        end
+
+        # Initialize the params/contract and freeze.
+        #
+        # @param env [Hash] a Rack env or an hash of params.
+        #
+        # @return [Params]
+        #
+        # @since 2.2.0
+        # @api private
+        def initialize(env)
+          @env = env
+          @input = Hanami::Action::ParamsExtraction.new(env).call
+          validation = validate
+          @params = validation.to_h
+          @errors = Errors.new(validation.messages)
+          freeze
+        end
+
+        # Provide the same initialize method for both params and contract
+        # since 2.2.0
+        # @api private
+        def self.included(klass)
+          klass.send :prepend, ActionValidations
+        end
+
+        # Returns structured error messages
+        #
+        # @return [Hash]
+        #
+        # @since 2.2.0
+        #
+        # @example
+        #   params.errors
+        #     # => {
+        #            :email=>["is missing", "is in invalid format"],
+        #            :name=>["is missing"],
+        #            :tos=>["is missing"],
+        #            :age=>["is missing"],
+        #            :address=>["is missing"]
+        #          }
+        attr_reader :errors
+
+        # Returns flat collection of full error messages
+        #
+        # @return [Array]
+        #
+        # @since 2.2.0
+        #
+        # @example
+        #   params.error_messages
+        #     # => [
+        #            "Email is missing",
+        #            "Email is in invalid format",
+        #            "Name is missing",
+        #            "Tos is missing",
+        #            "Age is missing",
+        #            "Address is missing"
+        #          ]
+        def error_messages(error_set = errors)
+          error_set.each_with_object([]) do |(key, messages), result|
+            k = Utils::String.titleize(key)
+
+            msgs = if messages.is_a?(::Hash)
+                     error_messages(messages)
+                   else
+                     messages.map { |message| "#{k} #{message}" }
+                   end
+
+            result.concat(msgs)
+          end
+        end
+
+        # Returns true if no validation errors are found,
+        # false otherwise.
+        #
+        # @return [TrueClass, FalseClass]
+        #
+        # @since 2.2.0
+        #
+        # @example
+        #   params.valid? # => true
+        def valid?
+          errors.empty?
+        end
+
+        # Pattern-matching support
+        #
+        # @return [::Hash]
+        #
+        # @since 2.2.0
+        def deconstruct_keys(*)
+          to_hash
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hanami/action/request_params/base.rb b/lib/hanami/action/request_params/base.rb
new file mode 100644
index 00000000..978b2ad6
--- /dev/null
+++ b/lib/hanami/action/request_params/base.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Hanami
+  class Action
+    module RequestParams
+      # Where ActionValidations provides common interface for handling validations, this Base modules handles the most
+      # common and basic methods for accessing params in an action. It is included in both BaseParams and Contract
+      # classes since Params inherits from BaseParams and Contract is more independent cause it delegates to
+      # Dry::Validation quickly
+      #
+      # Params is also coupled with Hanami::Validations::Form and this Base module is a way to breach the gap and make
+      # them more compatible.
+      #
+      # since 2.2.0
+      # @api private
+      module Base
+        # @since 2.2.0
+        # @api public
+        def get(*keys)
+          @params.dig(*keys)
+        end
+
+        # This is for compatibility with Hanami::Helpers::FormHelper::Values
+        #
+        # @api private
+        # @since 2.2.0
+        alias_method :dig, :get
+
+        # Returns raw params from Rack env
+        #
+        # @return [Hash]
+        #
+        # @since 2.2.0
+        def raw
+          @input
+        end
+
+        # Returns a hash of the parsed request params.
+        #
+        # @return [Hash]
+        #
+        # @since 2.2.0
+        # @api public
+        def to_h
+          @params
+        end
+        alias_method :to_hash, :to_h
+
+        # Iterates over the params.
+        #
+        # Calls the given block with each param key-value pair; returns the full hash of params.
+        #
+        # @yieldparam key [Symbol]
+        # @yieldparam value [Object]
+        #
+        # @return [to_h]
+        #
+        # @since 2.2.0
+        # @api public
+        def each(&blk)
+          to_h.each(&blk)
+        end
+
+        # Returns the value for the given params key.
+        #
+        # @param key [Symbol] the key
+        #
+        # @return [Object,nil] the associated value, if found
+        #
+        # @since 2.2.0
+        # @api public
+        def [](key)
+          @params[key]
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hanami/action/validatable.rb b/lib/hanami/action/validatable.rb
index 6eaceb64..a07df3bd 100644
--- a/lib/hanami/action/validatable.rb
+++ b/lib/hanami/action/validatable.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 require_relative "params"
+require_relative "contract"
 
 module Hanami
   class Action
@@ -17,6 +18,12 @@ module Validatable
       # @since 0.3.0
       PARAMS_CLASS_NAME = "Params"
 
+      # Defines the contract base class
+      #
+      # @api private
+      # @since 2.2.0
+      CONTRACT_CLASS_NAME = "Contract"
+
       # @api private
       # @since 0.1.0
       def self.included(base)
@@ -102,7 +109,16 @@ def params(klass = nil, &blk)
             klass = const_set(PARAMS_CLASS_NAME, Class.new(Params))
             klass.class_eval { params(&blk) }
           end
+          @params_class = klass
+        end
 
+        # @since 2.2.0
+        # @api public
+        def contract(klass = nil, &blk)
+          if klass.nil?
+            klass = const_set(CONTRACT_CLASS_NAME, Class.new(Contract))
+            klass.class_eval { contract(&blk) }
+          end
           @params_class = klass
         end
       end
diff --git a/spec/isolation/without_hanami_validations_spec.rb b/spec/isolation/without_hanami_validations_spec.rb
index d0568d3f..e5e544d1 100644
--- a/spec/isolation/without_hanami_validations_spec.rb
+++ b/spec/isolation/without_hanami_validations_spec.rb
@@ -15,6 +15,10 @@
     expect(defined?(Hanami::Action::Params)).to be(nil)
   end
 
+  it "doesn't load Hanami::Action::Contract" do
+    expect(defined?(Hanami::Action::Contract)).to be(nil)
+  end
+
   it "doesn't have params DSL" do
     expect do
       Class.new(Hanami::Action) do
@@ -28,6 +32,24 @@
     )
   end
 
+  it "doesn't have contract DSL" do
+    expect do
+      Class.new(Hanami::Action) do
+        contract do
+          params do
+            required(:start_date).value(:date)
+          end
+          rule(:start_date) do
+            key.failure("must be in the future") if value <= Date.today
+          end
+        end
+      end
+    end.to raise_error(
+      NoMethodError,
+      /To use `contract`, please add 'hanami\/validations' gem to your Gemfile/
+    )
+  end
+
   it "has params that don't respond to .valid?" do
     action = Class.new(Hanami::Action) do
       def handle(req, res)
diff --git a/spec/support/fixtures.rb b/spec/support/fixtures.rb
index ac803e03..55087772 100644
--- a/spec/support/fixtures.rb
+++ b/spec/support/fixtures.rb
@@ -1902,3 +1902,121 @@ def call(env)
     end
   end
 end
+
+class ContractAction < Hanami::Action
+  contract do
+    params do
+      required(:birth_date).filled(:date)
+      required(:book).schema do
+        required(:title).filled(:str?)
+      end
+    end
+
+    rule(:birth_date) do
+      key.failure("you must be 18 years or older") if value < Date.today << (12 * 18)
+    end
+  end
+
+  def handle(request, response)
+    if request.params.valid?
+      response.status = 201
+      response.body = JSON.generate(
+        new_name: request.params[:book][:title].upcase
+      )
+    else
+      response.body = {errors: request.params.errors.to_h}
+      response.status = 302
+    end
+  end
+end
+
+class BaseContract < Hanami::Action::Contract
+  contract do
+    params do
+      required(:start_date).value(:date)
+    end
+
+    rule(:start_date) do
+      key.failure("must be in the future") if value <= Date.today
+    end
+  end
+end
+
+class NestedContractParams < Hanami::Action::Contract
+  contract do
+    params do
+      required(:signup).schema do
+        required(:name).filled(:str?)
+        required(:age).filled(:int?, gteq?: 18)
+      end
+    end
+  end
+end
+
+AddressSchema = Dry::Schema.Params do
+  required(:country).value(:string)
+  required(:zipcode).value(:string)
+  required(:street).value(:string)
+end
+
+ContactSchema = Dry::Schema.Params do
+  required(:email).value(:string)
+  required(:mobile).value(:string)
+end
+
+class OutsideSchemasContract < Hanami::Action::Contract
+  contract do
+    params(AddressSchema, ContactSchema) do
+      required(:name).value(:string)
+      required(:age).value(:integer)
+    end
+  end
+end
+
+class TestContract < Hanami::Action::Contract
+  contract do
+    params do
+      required(:name).value(:string)
+      required(:email).filled(format?: /\A.+@.+\z/)
+      required(:tos).filled(:bool?)
+      required(:age).filled(:int?)
+      required(:address).schema do
+        required(:line_one).filled
+        required(:deep).schema do
+          required(:deep_attr).filled(:str?)
+        end
+      end
+
+      optional(:array).maybe do
+        each do
+          schema do
+            required(:name).filled(:str?)
+          end
+        end
+      end
+    end
+
+    rule(:name) do
+      key.failure("must be Luca") if value != "Luca"
+    end
+  end
+end
+
+class WhitelistedUploadDslContractAction < Hanami::Action
+  contract do
+    params do
+      required(:id).maybe(:integer)
+      required(:upload).filled
+    end
+  end
+
+  def handle(req, res)
+    res.body = req.params.to_h.inspect
+  end
+end
+
+class RawContractAction < Hanami::Action
+  def handle(req, res)
+    res.body = req.params.to_h.inspect
+  end
+end
diff --git a/spec/unit/hanami/action/contract_spec.rb b/spec/unit/hanami/action/contract_spec.rb
new file mode 100644
index 00000000..bcb46344
--- /dev/null
+++ b/spec/unit/hanami/action/contract_spec.rb
@@ -0,0 +1,357 @@
+# frozen_string_literal: true
+
+require "rack"
+
+RSpec.describe Hanami::Action::Contract do
+  describe "when defined as block in action" do
+    let(:action) { ContractAction.new }
+    context "when it has errors" do
+      it "returns them" do
+        response = action.call("birth_date" => "2000-01-01")
+
+        expect(response.status).to eq 302
+        expect(response.body).to eq ["{:errors=>{:book=>[\"is missing\"], :birth_date=>[\"you must be 18 years or older\"]}}"]
+      end
+    end
+
+    context "when it is valid" do
+      it "works" do
+        response = action.call("birth_date" => Date.today - (365 * 15), "book" => {"title" => "Hanami"})
+
+        expect(response.status).to eq 201
+        expect(response.body).to eq ["{\"new_name\":\"HANAMI\"}"]
+      end
+    end
+  end
+
+  describe "works as a standalone contract class" do
+    it "validates the input" do
+      contract = BaseContract.new(start_date: "2000-01-01")
+
+      expect(contract.errors.to_h).to eq(start_date: ["must be in the future"])
+    end
+
+    it "allows for usage of outside classes as schemas" do
+      contract = OutsideSchemasContract.new(country: "PL", zipcode: "11-123", mobile: "123092902", name: "myguy")
+
+      expect(contract.errors.to_h).to eq(
+        street: ["is missing"],
+        email: ["is missing"],
+        age: ["is missing"]
+      )
+    end
+  end
+
+  describe "#raw" do
+    let(:params) { Class.new(Hanami::Action::Contract) }
+
+    context "when this feature isn't enabled" do
+      let(:action) { RawContractAction.new }
+
+      it "raw gets all params" do
+        File.open("spec/support/fixtures/multipart-upload.png", "rb") do |upload|
+          response = action.call("id" => "1", "unknown" => "2", "upload" => upload)
+
+          expect(response[:params][:id]).to eq("1")
+          expect(response[:params][:unknown]).to eq("2")
+          expect(FileUtils.cmp(response[:params][:upload], upload)).to be(true)
+
+          expect(response[:params].raw.fetch("id")).to eq("1")
+          expect(response[:params].raw.fetch("unknown")).to eq("2")
+          expect(response[:params].raw.fetch("upload")).to eq(upload)
+        end
+      end
+    end
+
+    context "when this feature is enabled" do
+      let(:action) { WhitelistedUploadDslContractAction.new }
+
+      it "raw gets all params" do
+        Tempfile.create("multipart-upload") do |upload|
+          response = action.call("id" => "1", "unknown" => "2", "upload" => upload, "_csrf_token" => "3")
+
+          expect(response[:params][:id]).to          eq(1)
+          expect(response[:params][:unknown]).to     be(nil)
+          expect(response[:params][:upload]).to      eq(upload)
+
+          expect(response[:params].raw.fetch("id")).to          eq("1")
+          expect(response[:params].raw.fetch("unknown")).to     eq("2")
+          expect(response[:params].raw.fetch("upload")).to      eq(upload)
+        end
+      end
+    end
+  end
+
+  describe "validations" do
+    it "isn't valid with empty params" do
+      params = TestContract.new({})
+
+      expect(params.valid?).to be(false)
+
+      expect(params.errors.fetch(:email)).to   eq(["is missing"])
+      expect(params.errors.fetch(:name)).to    eq(["is missing"])
+      expect(params.errors.fetch(:tos)).to     eq(["is missing"])
+      expect(params.errors.fetch(:address)).to eq(["is missing"])
+
+      expect(params.error_messages).to eq(["Name is missing", "Email is missing", "Tos is missing", "Age is missing", "Address is missing"])
+    end
+
+    it "isn't valid with empty nested params" do
+      params = NestedContractParams.new(signup: {})
+
+      expect(params.valid?).to be(false)
+
+      expect(params.errors.fetch(:signup).fetch(:name)).to eq(["is missing"])
+
+      with_hanami_validations(1) do
+        expect(params.error_messages).to eq(["Name is missing", "Age is missing", "Age must be greater than or equal to 18"])
+      end
+
+      with_hanami_validations(2) do
+        expect(params.error_messages).to eq(["Name is missing", "Age is missing"])
+      end
+    end
+
+    it "is it valid when all the validation criteria are met" do
+      params = TestContract.new(email: "test@hanamirb.org",
+                                password: "123456",
+                                password_confirmation: "123456",
+                                name: "Luca",
+                                tos: true,
+                                age: 34,
+                                address: {
+                                  line_one: "10 High Street",
+                                  deep: {
+                                    deep_attr: "blue"
+                                  }
+                                })
+
+      expect(params.valid?).to         be(true)
+      expect(params.errors).to         be_empty
+      expect(params.error_messages).to be_empty
+    end
+
+    it "has input available through the hash accessor" do
+      params = TestContract.new(name: "John", age: "1", address: {line_one: "10 High Street"})
+
+      expect(params[:name]).to               eq("John")
+      expect(params[:age]).to                be("1")
+      expect(params[:address][:line_one]).to eq("10 High Street")
+    end
+
+    it "allows nested hash access via symbols" do
+      params = TestContract.new(name: "John", address: {line_one: "10 High Street", deep: {deep_attr: 1}})
+      expect(params[:name]).to                       eq("John")
+      expect(params[:address][:line_one]).to         eq("10 High Street")
+      expect(params[:address][:deep][:deep_attr]).to be(1)
+    end
+  end
+
+  describe "#get" do
+    context "with data" do
+      let(:params) do
+        TestContract.new(
+          name: "Luca",
+          address: {line_one: "10 High Street", deep: {deep_attr: 1}},
+          array: [{name: "Lennon"}, {name: "Wayne"}]
+        )
+      end
+
+      it "returns nil for nil argument" do
+        expect(params.get(nil)).to be(nil)
+      end
+
+      it "returns nil for unknown param" do
+        expect(params.get(:unknown)).to be(nil)
+      end
+
+      it "allows to read top level param" do
+        expect(params.get(:name)).to eq("Luca")
+      end
+
+      it "allows to read nested param" do
+        expect(params.get(:address, :line_one)).to eq("10 High Street")
+      end
+
+      it "returns nil for unknown nested param" do
+        expect(params.get(:address, :unknown)).to be(nil)
+      end
+
+      it "allows to read data under arrays" do
+        expect(params.get(:array, 0, :name)).to eq("Lennon")
+        expect(params.get(:array, 1, :name)).to eq("Wayne")
+      end
+    end
+
+    context "without data" do
+      let(:params) { TestContract.new({}) }
+
+      it "returns nil for nil argument" do
+        expect(params.get(nil)).to be(nil)
+      end
+
+      it "returns nil for unknown param" do
+        expect(params.get(:unknown)).to be(nil)
+      end
+
+      it "returns nil for top level param" do
+        expect(params.get(:name)).to be(nil)
+      end
+
+      it "returns nil for nested param" do
+        expect(params.get(:address, :line_one)).to be(nil)
+      end
+
+      it "returns nil for unknown nested param" do
+        expect(params.get(:address, :unknown)).to be(nil)
+      end
+    end
+  end
+
+  context "without data" do
+    let(:params) { TestContract.new({}) }
+
+    it "returns nil for nil argument" do
+      expect(params.get(nil)).to be(nil)
+    end
+
+    it "returns nil for unknown param" do
+      expect(params.get(:unknown)).to be(nil)
+    end
+
+    it "returns nil for top level param" do
+      expect(params.get(:name)).to be(nil)
+    end
+
+    it "returns nil for nested param" do
+      expect(params.get(:address, :line_one)).to be(nil)
+    end
+
+    it "returns nil for unknown nested param" do
+      expect(params.get(:address, :unknown)).to be(nil)
+    end
+  end
+
+  describe "#deconstruct_keys" do
+    it "supports pattern-matching" do
+      contract = TestContract.new(name: "Luca")
+      contract => { name: }
+      expect(name).to eq("Luca")
+    end
+  end
+
+  describe "#to_h" do
+    let(:params) { TestContract.new(name: "Luca") }
+
+    it "returns a ::Hash" do
+      expect(params.to_hash).to be_kind_of(::Hash)
+    end
+
+    it "returns unfrozen Hash" do
+      expect(params.to_hash).to_not be_frozen
+    end
+
+    it "handles nested params" do
+      input = {
+        "address" => {
+          "deep" => {
+            "deep_attr" => "foo"
+          }
+        }
+      }
+
+      expected = {
+        address: {
+          deep: {
+            deep_attr: "foo"
+          }
+        }
+      }
+
+      actual = TestContract.new(input).to_hash
+      expect(actual).to eq(expected)
+
+      expect(actual).to                  be_kind_of(::Hash)
+      expect(actual[:address]).to        be_kind_of(::Hash)
+      expect(actual[:address][:deep]).to be_kind_of(::Hash)
+    end
+  end
+
+  describe "#to_hash" do
+    let(:params) { TestContract.new(name: "Luca") }
+
+    it "returns a ::Hash" do
+      expect(params.to_hash).to be_kind_of(::Hash)
+    end
+
+    it "returns unfrozen Hash" do
+      expect(params.to_hash).to_not be_frozen
+    end
+
+    it "handles nested params" do
+      input = {
+        "address" => {
+          "deep" => {
+            "deep_attr" => "foo"
+          }
+        }
+      }
+
+      expected = {
+        address: {
+          deep: {
+            deep_attr: "foo"
+          }
+        }
+      }
+
+      actual = TestContract.new(input).to_hash
+      expect(actual).to eq(expected)
+
+      expect(actual).to                  be_kind_of(::Hash)
+      expect(actual[:address]).to        be_kind_of(::Hash)
+      expect(actual[:address][:deep]).to be_kind_of(::Hash)
+    end
+  end
+
+  describe "#errors" do
+    let(:klass) do
+      Class.new(described_class) do
+        contract do
+          params do
+            required(:birth_date).filled(:date)
+          end
+
+          rule(:birth_date) do
+            key.failure("you must be 18 years or older") if value < Date.today << (12 * 18)
+          end
+        end
+      end
+    end
+
+    let(:params) { klass.new(birth_date: Date.today) }
+
+    it "is of type Hanami::Action::Params::Errors" do
+      expect(params.errors).to be_kind_of(Hanami::Action::Params::Errors)
+    end
+
+    it "affects #valid?" do
+      expect(params).to be_valid
+
+      params.errors.add(:birth_date, "is not unique")
+      expect(params).to_not be_valid
+    end
+
+    it "appends message to already existing messages" do
+      params = klass.new(birth_date: "")
+      params.errors.add(:birth_date, "is invalid")
+
+      expect(params.error_messages).to eq(["Birth Date must be filled", "Birth Date is invalid"])
+    end
+
+    it "is included in #error_messages" do
+      params.errors.add(:birth_date, "is not unique")
+      expect(params.error_messages).to eq(["Birth Date is not unique"])
+    end
+  end
+end
diff --git a/spec/unit/hanami/action/params_spec.rb b/spec/unit/hanami/action/params_spec.rb
index da356999..170a4985 100644
--- a/spec/unit/hanami/action/params_spec.rb
+++ b/spec/unit/hanami/action/params_spec.rb
@@ -495,7 +495,7 @@
       expect(params).to_not be_valid
     end
 
-    it "appens message to already existing messages" do
+    it "appends message to already existing messages" do
       params = klass.new(book: {})
       params.errors.add(:book, :code, "is invalid")